From cfce8f05136a1bc601ad396ac329fe7ccb05ecb2 Mon Sep 17 00:00:00 2001 From: forestmvey Date: Wed, 25 Sep 2024 10:50:13 -0700 Subject: [PATCH 01/11] Initial commit for the InfluxDB Timestream Connector. Signed-off-by: forestmvey --- .gitignore | 4 - .../influxdb-timestream-connector/Cargo.lock | 2549 +++++++++++++++++ .../influxdb-timestream-connector/Cargo.toml | 33 + .../influxdb-timestream-connector/README.md | 257 ++ .../docs/adr/require-line-protocol-tags.md | 27 + ...estream_connector_lambda_function_arch.png | Bin 0 -> 146934 bytes .../influxdb-timestream-connector/src/lib.rs | 119 + .../src/line_protocol_parser.rs | 78 + .../src/line_protocol_parser/tests.rs | 748 +++++ .../influxdb-timestream-connector/src/main.rs | 16 + .../src/metric.rs | 60 + .../src/records_builder.rs | 136 + .../multi_table_multi_measure_builder.rs | 114 + .../src/records_builder/tests.rs | 391 +++ .../src/timestream_utils.rs | 165 ++ .../tests/integration_test.rs | 1171 ++++++++ .../sample-influxdb-clients/README.md | 7 + .../data/bird-migration.line | 2002 +++++++++++++ .../sample-influxdb-clients/go/README.md | 43 + .../sample-influxdb-clients/go/go.mod | 29 + .../sample-influxdb-clients/go/go.sum | 119 + .../go/line-protocol-client-demo.go | 117 + 22 files changed, 8181 insertions(+), 4 deletions(-) create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/Cargo.lock create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/README.md create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/docs/adr/require-line-protocol-tags.md create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/docs/img/influxdb_timestream_connector_lambda_function_arch.png create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/src/line_protocol_parser.rs create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/src/line_protocol_parser/tests.rs create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/src/main.rs create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/src/metric.rs create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_table_multi_measure_builder.rs create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/tests.rs create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs create mode 100644 integrations/influxdb_connector/sample-influxdb-clients/README.md create mode 100644 integrations/influxdb_connector/sample-influxdb-clients/data/bird-migration.line create mode 100644 integrations/influxdb_connector/sample-influxdb-clients/go/README.md create mode 100644 integrations/influxdb_connector/sample-influxdb-clients/go/go.mod create mode 100644 integrations/influxdb_connector/sample-influxdb-clients/go/go.sum create mode 100644 integrations/influxdb_connector/sample-influxdb-clients/go/line-protocol-client-demo.go diff --git a/.gitignore b/.gitignore index 28883687..82d44953 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,6 @@ bin/ obj/ -# Ignore common go files -go.mod -go.sum - # Ignore common Java files .idea/ *.iml diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.lock b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.lock new file mode 100644 index 00000000..806f2c74 --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.lock @@ -0,0 +1,2549 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "aws-config" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e95816a168520d72c0e7680c405a5a8c1fb6a035b4bc4b9d7b0de8e1a941697" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 0.2.12", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60e8f6b615cb5fc60a98132268508ad104310f0cfb25a1c22eee76efdf9154da" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-runtime" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2424565416eef55906f9f8cece2072b6b6a76075e3ff81483ebe938a89a4c05f" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http-body 0.4.6", + "once_cell", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4abf69a87be33b6f125a93d5046b5f7395c26d1f449bf8d3927f5577463b6de0" +dependencies = [ + "ahash", + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http-body 0.4.6", + "lru", + "once_cell", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11822090cf501c316c6f75711d77b96fba30658e3867a7762e5e2f5d32d31e81" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "http 0.2.12", + "once_cell", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78a2a06ff89176123945d1bbe865603c4d7101bea216a550bb4d2e4e9ba74d74" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "http 0.2.12", + "once_cell", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20a91795850826a6f456f4a48eff1dfa59a0e69bdbf5b8c50518fd372106574" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "http 0.2.12", + "once_cell", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-timestreamwrite" +version = "1.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87050ae54cd21499f85cb7192adf71295c1a34c2d0ce9b823f784cb0be770561" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "once_cell", + "regex-lite", + "tokio", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df1b0fa6be58efe9d4ccc257df0a53b89cd8909e86591a13ca54817c87517be" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.1.0", + "once_cell", + "p256", + "percent-encoding", + "ring", + "sha2", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62220bc6e97f946ddd51b5f1361f78996e704677afc518a4ff66b7a72ea1378c" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.60.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598b1689d001c4d4dc3cb386adb07d37786783aee3ac4b324bcadac116bf3d23" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc32c", + "crc32fast", + "hex", + "http 0.2.12", + "http-body 0.4.6", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6363078f927f612b970edf9d1903ef5cef9a64d1e8423525ebb1f0a1633c858" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.60.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01dbcb6e2588fd64cfb6d7529661b06466419e4c54ed1c62d6510d2d0350a728" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http-body 0.4.6", + "once_cell", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.60.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4683df9469ef09468dad3473d129960119a0d3593617542b7d52086c8486f2d6" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2fbd61ceb3fe8a1cb7352e42689cec5335833cd9f94103a61e98f9bb61c64bb" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce695746394772e7000b39fe073095db6d45a862d0767dd5ad0ac0d7f8eb87" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "http-body 1.0.0", + "httparse", + "hyper 0.14.29", + "hyper-rustls", + "once_cell", + "pin-project-lite", + "pin-utils", + "rustls", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e086682a53d3aa241192aa110fa8dfce98f2f5ac2ead0de84d41582c7e8fdb96" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.1.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273dcdfd762fae3e1650b8024624e7cd50e484e37abdab73a7a706188ad34543" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.1.0", + "http-body 0.4.6", + "http-body 1.0.0", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d123fbc2a4adc3c301652ba8e149bf4bc1d1725affb9784eb20c953ace06bf55" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5221b91b3e441e6675310829fd8984801b772cb1546ef6c0e54dec9f1ac13fef" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "aws_lambda_events" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7319a086b79c3ff026a33a61e80f04fd3885fbb73237981ea080d21944e1cb1c" +dependencies = [ + "base64 0.22.1", + "bytes", + "http 1.1.0", + "http-body 1.0.0", + "http-serde", + "query_map", + "serde", + "serde_json", +] + +[[package]] +name = "backtrace" +version = "0.3.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytes" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" +dependencies = [ + "serde", +] + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "cc" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b6a57f98764a267ff415d50a25e6e166f3831a5071af4995296ea97d210490" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "cloudabi" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddfc5b9aa5d4507acaf872de71051dfd0e309860e88966e1051e462a077aac4f" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32c" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" + +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "pin-project-lite", +] + +[[package]] +name = "http-serde" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f056c8559e3757392c8d091e796416e4649d8e49e88b8d76df6c002f05027fd" +dependencies = [ + "http 1.1.0", + "serde", +] + +[[package]] +name = "httparse" +version = "1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f361cde2f109281a220d4307746cdfd5ee3f410da58a70377762396775634b33" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.29", + "log", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "hyper-util" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "hyper 1.4.1", + "pin-project-lite", + "socket2", + "tokio", + "tower", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "influxdb-line-protocol" +version = "1.0.0" +source = "git+https://github.com/influxdata/influxdb3_core?rev=d81f63ddc10e3cf1c28b05e6c1cef03b71da7f8a#d81f63ddc10e3cf1c28b05e6c1cef03b71da7f8a" +dependencies = [ + "bytes", + "log", + "nom", + "smallvec", + "snafu", +] + +[[package]] +name = "influxdb-timestream-connector" +version = "0.1.0" +dependencies = [ + "anyhow", + "aws-config", + "aws-credential-types", + "aws-runtime", + "aws-sdk-s3", + "aws-sdk-timestreamwrite", + "aws-types", + "chrono", + "influxdb-line-protocol", + "lambda_http", + "rand", + "serde", + "tokio", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lambda_http" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fe279be7f89f5f72c97c3a96f45c43db8edab1007320ecc6a5741273b4d6db" +dependencies = [ + "aws_lambda_events", + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.4.1", + "lambda_runtime", + "mime", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "tokio-stream", + "url", +] + +[[package]] +name = "lambda_runtime" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed49669d6430292aead991e19bf13153135a884f916e68f32997c951af637ebe" +dependencies = [ + "anyhow", + "async-stream", + "base64 0.22.1", + "bytes", + "futures", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "http-serde", + "hyper 1.4.1", + "hyper-util", + "lambda_runtime_api_client", + "pin-project", + "serde", + "serde_json", + "serde_path_to_error", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tracing", +] + +[[package]] +name = "lambda_runtime_api_client" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c90a10f094475a34a04da2be11686c4dcfe214d93413162db9ffdff3d3af293a" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.0", + "http-body-util", + "hyper 1.4.1", + "hyper-util", + "tokio", + "tower", + "tower-service", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "lru" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi", + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "081b846d1d56ddfc18fdf1a922e4f6e07a11768ea1b92dec44e42b72712ccfce" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "outref" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4030760ffd992bef45b0ae3f10ce1aba99e33464c90d14dd7c039884963ddc7a" + +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "query_map" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eab6b8b1074ef3359a863758dae650c7c0c6027927a085b7af911c8e0bf3a15" +dependencies = [ + "form_urlencoded", + "serde", + "serde_derive", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c618c47cd3ebd209790115ab837de41425723956ad3ce2e6a7f09890947cacb9" +dependencies = [ + "cloudabi", + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "winapi", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" +dependencies = [ + "bitflags 2.6.0", +] + +[[package]] +name = "regex" +version = "1.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.7", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.4", +] + +[[package]] +name = "regex-lite" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" + +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" +dependencies = [ + "bitflags 2.6.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "serde" +version = "1.0.208" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff085d2cb684faa248efb494c39b68e522822ac0de72ccf08109abde717cfb2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.208" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24008e81ff7613ed8e5ba0cfaf24e2c2f1e5b8a0495711e44fcd4882fca62bcf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.124" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66ad62847a56b3dba58cc891acd13884b9c61138d330c0d7b6181713d4fce38d" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "snafu" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b835cb902660db3415a672d862905e791e54d306c6e8189168c7f3d9ae1c79d" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1e02fca405f6280643174a50c942219f0bbf4dbf7d480f1dd864d6f211ae5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c55115c6fbe2d2bef26eb09ad74bde02d8255476fc0c7b515ef09fbb35742d82" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.39.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babc99b9923bfa4804bd74722ff02c0381021eafa4db9949217e3be8e84fff5" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-serde" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "once_cell", + "regex", + "serde", + "serde_json", + "sharded-slab", + "thread_local", + "tracing", + "tracing-core", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "uuid" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439" + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml new file mode 100644 index 00000000..aac6e30f --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "influxdb-timestream-connector" +version = "0.1.0" +edition = "2021" + +[dependencies.lambda_http] +version = "0.13.0" +default-features = false +features = ["apigw_http", "anyhow", "tracing"] + +[dependencies] +anyhow = "1.0.86" +aws-config = { version = "1.5.5", features = ["behavior-version-latest"] } +aws-credential-types = "1.2.1" +aws-runtime = "1.4.2" +aws-sdk-s3 = "1.46.0" +aws-sdk-timestreamwrite = "1.39.0" +aws-types = "1.3.3" +chrono = "0.4.38" +influxdb-line-protocol = { git = "https://github.com/influxdata/influxdb3_core", rev = "d81f63ddc10e3cf1c28b05e6c1cef03b71da7f8a" } +rand = "0.5.0" +serde = { version = "1.0.208", features = ["derive"] } +tokio = { version = "1.39.3", features = ["full"] } + +[package.metadata.lambda.env] +region = "us-east-1" +database_name = "influxdb-line-protocol" +measure_name_for_multi_measure_records = "influxdb-measure" +enable_database_creation = "true" +enable_table_creation = "true" +enable_mag_store_writes = "true" +mag_store_retention_period = "8000" +mem_store_retention_period = "12" diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/README.md b/integrations/influxdb_connector/influxdb-timestream-connector/README.md new file mode 100644 index 00000000..905deecd --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/README.md @@ -0,0 +1,257 @@ +# InfluxDB Timestream Connector + +## Overview + +The InfluxDB Timestream connector allows [line protocol](https://docs.influxdata.com/influxdb/v2/reference/syntax/line-protocol/) to be ingested to [Amazon Timestream for LiveAnalytics](https://aws.amazon.com/timestream/). The connector parses ingested line protocol and maps the data to multi-measure records for ingestion into Timestream for LiveAnalytics using the [Timestream Write API](https://docs.aws.amazon.com/timestream/latest/developerguide/API_Operations_Amazon_Timestream_Write.html). + +### Architecture + +The following diagram shows a high-level overview of the connector's architecture when deployed as an [AWS Lambda](https://aws.amazon.com/lambda/) function. + + + +## Table Schema + +### Multi-Table Multi-Measure + +The following table shows how the connector maps line protocol elements to Timestream for LiveAnalytics record elements. + +| Line Protocol Element | Timestream Record Attribute | +|-----------------------|---------------------------| +| Timestamp | Time | +| Tags | Dimensions | +| Fields | Measures | +| Measurements | Table names | + +A Timestream record's `measure_name` field is not derived from any element of ingested line protocol. Due to the multi-measure record translation, the connector sets the `measure_name` for each multi-measure record to the value of a Lambda environment variable. + +The following example shows the translation of a single line protocol point into a Timestream for LiveAnalytics table, using a Timestamp with second precision and a Lambda environment variable configured to `influxdb-measure`: + +#### Line Protocol Point + +``` +cpu_load_short,host=server01,region=us-west value=0.64,average=1.24, 1725059274 +``` + +#### Resulting cpu_load_short Timestream for LiveAnalytics Table + +| host | region | measure_name | time | value | average | +|----------|---------|------------------|-------------------------------|-------|---------| +| server01 | us-west | influxdb-measure | 2024-08-30 23:07:54.000000000 | 0.64 | 1.24 | + +## Deployment Options + +### AWS CloudFormation Deployment + +The InfluxDB Timestream connector can be deployed within an AWS CloudFormation stack as an AWS Lambda function with an accompanying Amazon REST API Gateway. The API Gateway mimics the InfluxDB v2 API and provides the `/api/v2/write` endpoint for ingestion. + +#### Deploying a CloudFormation Stack Using SAM CLI + +The stack can be deployed using the [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) and `template.yml`. + +1. Run the following command replacing `` with the AWS region you want to deploy in and provide parameter overrides as desired: + + ```shell + sam --region --parameter-overrides ParameterKey=exampleKey,ParameterValue=exampleValue deploy template.yml + ``` +2. Once the stack has finished deploying, take note of the output "Endpoint" value. This value will be used as the endpoint for all write requests, for example, `/api/v2/write`. + +#### Stack Logs + +##### Viewing REST API Gateway Logs + +By default, execution and access logging is enabled for the REST API Gateway. + +To view logs: + +1. Visit the [AWS CloudFormation console](https://console.aws.amazon.com/cloudformation/home). +2. In the navigation pane, choose **Stacks**. +3. Choose your deployed stack from the list of stacks. +4. In the **Resources** tab choose the **Physical ID** of the REST API Gateway. +5. In the navigation pane, under **Monitor**, choose **Logging**. +6. From the dropdown menu, select the currently deployed stage. +7. Choose **View logs in CloudWatch**. + +##### Viewing Lambda Logs + +By default, logging is enabled for the deployed connector. + +To view logs: + +1. Visit the [AWS CloudFormation console](https://console.aws.amazon.com/cloudformation/home). +2. In the navigation pane, choose **Stacks**. +3. Choose your deployed stack from the list of stacks. +4. In the **Resources** tab choose the **Physical ID** of the Lambda function. +5. In the **Monitor** tab, choose **View CloudWatch logs**. + +### Local Deployment + +The connector can be run locally using [Cargo Lambda](https://www.cargo-lambda.info/guide/what-is-cargo-lambda.html). + +1. [Download and install Rust](https://www.rust-lang.org/tools/install). +2. [Download and install Cargo Lambda](https://www.cargo-lambda.info/guide/installation.html). +3. Configure the following environment variables: + - `region` string: the AWS region to use. Defaults to `us-east-1`. + - `database_name` string: the Timestream for LiveAnalytics database name to use. Defaults to `influxdb-line-protocol`. + - `measure_name_for_multi_measure_records` string: the value to use in records as the measure name. Defaults to `influxdb-measure`. +4. To run the connector on `http://localhost:9000` execute the following command: + + ```shell + cargo lambda watch + ``` +5. Send all requests to `http://localhost:9000/api/v2/write`. + +## Security + +### Encryption + +When the connector is deployed as part of a CloudFormation stack, the stack's API Gateway ensures all communication is protected by TLS 1.2+. + +### Authentication + +The REST API Gateway ensures all requests are authenticated with SigV4. Any InfluxDB user tokens or credentials are discarded. This authentication method cannot be correlated to IAM authorization. + +## IAM Permissions + +### IAM Deployment Permissions + +[//]: # (TODO: Update deployment policy with least privilege once REST API Gateway deployment has been tested.) + +The following is the least privileged IAM permissions for deploying the connector. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:CreateRole", + "iam:AttachRolePolicy", + "iam:UpdateAssumeRolePolicy", + "iam:PassRole", + "iam:PutRolePolicy" + ], + "Resource": [ + "arn:aws:iam::{account-id}:role/AWSLambdaBasicExecutionRole", + "arn:aws:iam::{account-id}:role/cargo-lambda-role*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "lambda:CreateFunction", + "lambda:UpdateFunctionCode", + "lambda:GetFunction", + "lambda:UpdateFunctionConfiguration", + "lambda:GetFunctionConfiguration", + "lambda:CreateFunctionUrlConfig" + ], + "Resource": "arn:aws:lambda:{region}:{account-id}:function:influxdb-timestream-connector" + } + ] +} +``` + +### IAM Execution Permissions + +The following is the least privileged IAM permissions for executing the connector. + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "timestream:WriteRecords", + "timestream:Select", + "timestream:DescribeTable", + "timestream:CreateTable" + ], + "Resource": "arn:aws:timestream:{region}:{account-id}:database/influxdb-line-protocol/table/*" + }, + { + "Effect": "Allow", + "Action": [ + "timestream:DescribeEndpoints" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "timestream:DescribeDatabase", + "timestream:CreateDatabase" + ], + "Resource": "arn:aws:timestream:{region}:{account-id}:database/influxdb-line-protocol" + } + ] +} +``` + +## Example Application with Go Client + +A demo Go client application is available under `sample-code/aws-timestream/sample-influxdb-clients/go/line-protocol-client-demo.go` that sends line protocol data to a local or deployed instance of the connector. A line protocol sample dataset is included in `sample-code/aws-timestream/sample-influxdb-clients/data/bird-migration.line`, which the demo application uses for ingestion. + +To configure the sample application and ingest all line protocol data contained in `bird-migration.line` to Timestream for LiveAnalytics, perform the following steps: + +1. [Download and install Go](https://go.dev/doc/install). +2. [Configure your AWS credentials for use by the AWS SDK for Rust](https://docs.aws.amazon.com/sdkref/latest/guide/creds-config-files.html). +3. [Deploy the Timestream Line Protocol connector locally](#local-deployment). +4. Navigate to `sample-code/aws-timestream/sample-influxdb-clients/go/`. +5. Run the sample Go client: + - With the connector deployed in a CloudFormation stack, replacing `` with the AWS region you deployed your stack in and `` with the endpoint of your deployed REST API Gateway: + ```shell + go run line-protocol-client-demo.go --region --service execute-api --endpoint + ``` + - With the connector deployed locally: + ```shell + go run line-protocol-client-demo.go + ``` +6. Run the following [AWS CLI](https://aws.amazon.com/cli/) command to verify data has been ingested to Timestream for LiveAnalytics, replacing `` with the region you used for ingesting data: + ```shell + aws timestream-query query \ + --region \ + --query-string 'SELECT * FROM "influxdb-line-protocol"."migration1" LIMIT 10' + ``` + +## Troubleshooting + +### Amazon Timestream Write API Errors + +| Error | Status Code | Description | Solution | +|-------|-------------|-------------|----------| +| `AccessDeniedException` | 400 | You are not authorized to perform this action. | Verify that your user has the [correct permissions](#iam-permissions). | +| `InternalServerException` | 500 | Timestream was unable to fully process this request because of an internal server error. | Try ingesting records at another time. | +| `RejectedRecordsException` | 400 | The records sent to Timestream were invalid. | Check the [line protocol limitations](#line-protocol-limitations) and [caveats](#caveats) and ensure your line protocol points abide by expected formatting. +| `ResourceNotFoundException` | 400 | The operation tried to access a nonexistent resource. The resource might not be specified correctly, or its status might not be ACTIVE. | Verify that your database exists in Timestream for LiveAnalytics. Consider setting `enable_database_creation` to `true` to allow the connector to create the database for you. | +| `ServiceQuotaExceededException` | 400 | The instance quota of resource exceeded for this account. | Confirm you are not exceeding Timestream for LiveAnalytics' quotas. If you are not exceeding any quotas, check the list of [line protocol limitations](#line-protocol-limitations) below, specifically the number of unique measurement names. | +| `ThrottlingException` | 400 | Too many requests were made by a user and they exceeded the service quotas. The request was throttled. | Decrease the rate of your requests. | + + +## Limitations + +### Line Protocol Limitations + +Due to the connector translating line protocol to Timestream records, line protocol must satisfy [Timestream for LiveAnalytics' quotas](https://docs.aws.amazon.com/timestream/latest/developerguide/ts-limits.html). + +| Line Protocol Component | Limitation | +|-------------------------|------------| +| Maximum size of a line protocol point, with `measure_name` included. | 2 Kilobytes | +| Number of unique tag keys per table. | 128 | +| Maximum tag key size. | 60 bytes | +| Maximum measurement name size. | 256 bytes | +| Maximum unique measurement names per database (using multi-table multi-measure schema). | 50,000 | +| Maximum field key size. | 256 bytes | +| Maximum number of fields per point. | 256 | +| Maximum field value size. | 2048 bytes | +| Maximum unique field key values. | 1024 | +| Latest valid timestamp. | Fifteen minutes in the future from the current time. | +| Oldest valid timestamp. | `mag_store_retention_period` days before the current time. | + +## Caveats + +### Line Protocol Tag Requirement + +In order to ingest to Timestream for LiveAnalytics, every line protocol point must include at least one tag. diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/docs/adr/require-line-protocol-tags.md b/integrations/influxdb_connector/influxdb-timestream-connector/docs/adr/require-line-protocol-tags.md new file mode 100644 index 00000000..83cc3ae1 --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/docs/adr/require-line-protocol-tags.md @@ -0,0 +1,27 @@ +# Requirement of Line Protocol to Include at Least One Tag + +## Status + +Accepted. + +## Context + +Ingesting data to Timestream using the AWS SDK for Rust requires building [records](https://docs.aws.amazon.com/timestream/latest/developerguide/API_Record.html). A record includes various fields. Among them is an array of dimensions, "metadata attributes of the time series." All records require at least one dimension. + +## Decision + +Line protocol uses the following format: +``` +,= = +``` +for example, using a timestamp in seconds: +``` +readings,fleet=Alberta velocity=52.13 1725050416 +``` +All components of a line protocol point are required except for tags. + +We have decided that all line protocol points ingested to Timestream using the InfluxDB Timestream connector must have at least one tag. This is due to the fact that we map tags to dimensions. + +## Consequences + +This means that some tweaking of ingested data is required on the user's side. Users must ensure all line protocol points have at least one tag. diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/docs/img/influxdb_timestream_connector_lambda_function_arch.png b/integrations/influxdb_connector/influxdb-timestream-connector/docs/img/influxdb_timestream_connector_lambda_function_arch.png new file mode 100644 index 0000000000000000000000000000000000000000..2d2d1a76f028397f032a66c8fa0b5705323c8a94 GIT binary patch literal 146934 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 literal 0 HcmV?d00001 diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs new file mode 100644 index 00000000..95b85769 --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs @@ -0,0 +1,119 @@ +use anyhow::{anyhow, Error, Result}; +use aws_sdk_timestreamwrite as timestream_write; +use lambda_http::{Body, IntoResponse, Request, RequestExt, Response}; +use line_protocol_parser::*; +use records_builder::*; +use std::collections::HashMap; +use std::io::prelude::*; +use std::{str, thread, time}; +use timestream_utils::*; + +pub mod line_protocol_parser; +pub mod metric; +pub mod records_builder; +pub mod timestream_utils; + +// The maximum number of database/table creation/delete API calls +// that can be made per second is 1. +pub static TIMESTREAM_API_WAIT_SECONDS: u64 = 1; + +async fn handle_body( + client: ×tream_write::Client, + body: &[u8], + precision: ×tream_write::types::TimeUnit, +) -> Result<(), Error> { + // Handle parsing body in request + + let line_protocol = str::from_utf8(body).unwrap(); + let metric_data = parse_line_protocol(line_protocol)?; + let multi_measure_builder = get_builder(SchemaType::MultiTableMultiMeasure(std::env::var( + "measure_name_for_multi_measure_records", + )?)); + + // Only currently supports multi-measure multi-table + let multi_table_batch = build_records(&multi_measure_builder, &metric_data, precision)?; + handle_multi_table_ingestion(client, multi_table_batch).await?; + Ok(()) +} + +async fn handle_multi_table_ingestion( + client: ×tream_write::Client, + records: HashMap>, +) -> Result<(), Error> { + // Ingestion for multi-measure schema type + + let database_name = std::env::var("database_name")?; + + match database_exists(client, &database_name).await { + Ok(true) => (), + Ok(false) => { + if database_creation_enabled()? { + thread::sleep(time::Duration::from_secs(TIMESTREAM_API_WAIT_SECONDS)); + create_database(client, &database_name).await?; + } else { + return Err(anyhow!( + "Database {} does not exist and database creation is not enabled", + database_name + )); + } + } + Err(error) => return Err(anyhow!(error)), + } + + for (table_name, _) in records.iter() { + match table_exists(client, &database_name, table_name).await { + Ok(true) => (), + Ok(false) => { + if table_creation_enabled()? { + thread::sleep(time::Duration::from_secs(TIMESTREAM_API_WAIT_SECONDS)); + create_table(client, &database_name, table_name, get_table_config()?).await? + } else { + return Err(anyhow!( + "Table {} does not exist and database creation is not enabled", + table_name + )); + } + } + Err(error) => println!("error checking table exists: {:?}", error), + } + } + + for (table_name, mut records) in records.into_iter() { + ingest_records(client, &database_name, &table_name, &mut records).await? + } + + Ok(()) +} + +pub async fn lambda_handler( + client: ×tream_write::Client, + event: Request, +) -> Result { + // Handler for lambda runtime + + let precision = match event + .query_string_parameters_ref() + .and_then(|params| params.first("precision")) + { + Some("ms") => timestream_write::types::TimeUnit::Milliseconds, + Some("us") => timestream_write::types::TimeUnit::Microseconds, + Some("s") => timestream_write::types::TimeUnit::Seconds, + _ => timestream_write::types::TimeUnit::Nanoseconds, + }; + + let data: Result, _> = event.body().bytes().collect(); + let data = data?; + + match handle_body(client, &data, &precision).await { + Ok(_) => Ok(Response::builder() + .status(200) + .header("content-type", "text/html") + .body(Body::Empty) + .map_err(Box::new)?), + Err(error) => Ok(Response::builder() + .status(400) + .header("content-type", "text/html") + .body(Body::Text(error.to_string())) + .map_err(Box::new)?), + } +} diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/line_protocol_parser.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/line_protocol_parser.rs new file mode 100644 index 00000000..24af9d66 --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/line_protocol_parser.rs @@ -0,0 +1,78 @@ +use crate::metric::{self, Metric}; +use anyhow::{anyhow, Error}; +use influxdb_line_protocol::{self, parse_lines, ParsedLine}; + +pub fn parse_line_protocol(line_protocol: &str) -> Result, Error> { + // Parses a string of line protocol to a vector of Metric structs, + + let parsed_lines = parse_lines(line_protocol); + let mut output_metrics: Vec = Vec::new(); + for line_result in parsed_lines { + match line_result { + Ok(line) => { + let new_metric = parsed_line_to_metric(line)?; + output_metrics.push(new_metric); + } + + Err(error) => { + return Err(anyhow!("Failed to parse line: {}", error.to_string())); + } + } + } + Ok(output_metrics) +} + +pub fn parsed_line_to_metric(parsed_line: ParsedLine) -> Result { + // Converts an influxdb_line_protocol ParsedLine struct to a Metric struct. + + let mut new_tags: Vec<(String, String)> = Vec::new(); + if let Some(tag_set) = parsed_line.series.tag_set.as_ref() { + for (tag_key, tag_value) in tag_set { + new_tags.push((tag_key.to_string(), tag_value.to_string())); + } + } + + let mut new_fields: Vec<(String, metric::FieldValue)> = Vec::new(); + for (field_key, field_value) in parsed_line.field_set.as_ref() { + match field_value { + influxdb_line_protocol::FieldValue::I64(int_value) => { + new_fields.push((field_key.to_string(), metric::FieldValue::I64(*int_value))); + } + + influxdb_line_protocol::FieldValue::U64(uint_value) => { + new_fields.push((field_key.to_string(), metric::FieldValue::U64(*uint_value))); + } + + influxdb_line_protocol::FieldValue::F64(float_value) => { + new_fields.push((field_key.to_string(), metric::FieldValue::F64(*float_value))); + } + + influxdb_line_protocol::FieldValue::String(string_value) => { + new_fields.push(( + field_key.to_string(), + metric::FieldValue::String(string_value.to_string()), + )); + } + + influxdb_line_protocol::FieldValue::Boolean(bool_value) => { + new_fields.push(( + field_key.to_string(), + metric::FieldValue::Boolean(*bool_value), + )); + } + } + } + + match parsed_line.timestamp { + Some(timestamp) => Ok(Metric::new( + parsed_line.series.measurement.to_string(), + Some(new_tags), + new_fields, + timestamp, + )), + None => Err(anyhow!("Failed to parse timestamp")), + } +} + +#[cfg(test)] +pub mod tests; diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/line_protocol_parser/tests.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/line_protocol_parser/tests.rs new file mode 100644 index 00000000..69f430fa --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/line_protocol_parser/tests.rs @@ -0,0 +1,748 @@ +use super::parse_line_protocol; +use crate::metric::{self, Metric}; + +fn metrics_are_equal(actual_metric: &Metric, expected_metric: &Metric) -> bool { + // Determines whether two Metric structs have equal values for all struct fields. + if actual_metric.name() != expected_metric.name() { + println!("Metric names are not equal"); + return false; + } + + if actual_metric.timestamp() != expected_metric.timestamp() { + println!("Metric timestamps are not equal"); + return false; + } + + if actual_metric.tags() != expected_metric.tags() { + println!("Metric Option value of tags did not match") + } + + if let (Some(actual_tags), Some(expected_tags)) = + (&actual_metric.tags(), &expected_metric.tags()) + { + if actual_tags.len() != expected_tags.len() { + println!("Metric number of tags are not equal"); + return false; + } + + for (i, (tag_key, tag_value)) in actual_tags.iter().enumerate() { + if *tag_key != *expected_tags[i].0 { + println!("Metric tag keys are not equal"); + return false; + } + + if *tag_value != *expected_tags[i].1 { + println!("Metric tag values are not equal"); + return false; + } + } + } + + if actual_metric.fields().len() != expected_metric.fields().len() { + println!("Metric number of fields are not equal"); + return false; + } + + for (i, (field_key, field_value)) in actual_metric.fields().iter().enumerate() { + if *field_key != *expected_metric.fields()[i].0 { + println!("Metric field keys are not equal"); + return false; + } + + if field_value != &expected_metric.fields()[i].1 { + println!("Metric field values are not equal"); + return false; + } + } + + true +} + +#[test] +fn test_parse_field_integer() -> Result<(), String> { + // Tests parsing a single valid line with an integer field value. + let lp = String::from("readings incline=125i 1577836800000"); + + let expected_metric = Metric::new( + "readings".to_string(), + None, + vec![("incline".to_string(), metric::FieldValue::I64(125))], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_field_float() -> Result<(), String> { + // Tests parsing a single valid line with a float field value. + let lp = String::from("readings incline=125 1577836800000"); + + let expected_metric = Metric::new( + "readings".to_string(), + None, + vec![("incline".to_string(), metric::FieldValue::F64(125.0))], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_field_string_double_quote() -> Result<(), String> { + // Tests parsing a single valid line with a string field value using double quotes. + let lp = String::from("readings incline=\"125\" 1577836800000"); + + let expected_metric = Metric::new( + "readings".to_string(), + None, + vec![( + "incline".to_string(), + metric::FieldValue::String("125".to_string()), + )], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_field_string_single_quote() -> Result<(), String> { + // Tests parsing a single valid line with a string field value using single quotes. + let lp = String::from("readings incline=\"\'125\'\" 1577836800000"); + + let expected_metric = Metric::new( + "readings".to_string(), + None, + vec![( + "incline".to_string(), + metric::FieldValue::String("\'125\'".to_string()), + )], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_field_boolean() -> Result<(), String> { + // Tests parsing a single valid line with a boolean field value. + let lp = String::from("readings incline=true 1577836800000"); + + let expected_metric = Metric::new( + "readings".to_string(), + None, + vec![("incline".to_string(), metric::FieldValue::Boolean(true))], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_field_boolean_invalid() -> Result<(), String> { + // Tests parsing a single invalid line with an invalid boolean field value. + let lp = String::from("readings incline=tree 1577836800000"); + + let output_metrics = parse_line_protocol(&lp); + + assert!(output_metrics.is_err()); + Ok(()) +} + +#[test] +fn test_parse_measurement_unescaped_equals() -> Result<(), String> { + // Tests parsing a single valid line where the measurement name includes an unescaped equals sign. + let lp = String::from("read=ings,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800000"); + + let expected_metric = Metric::new( + "read=ings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![ + ("incline".to_string(), metric::FieldValue::I64(125)), + ("fuel_usage".to_string(), metric::FieldValue::F64(21.30)), + ], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_measurement_underscore_begin() -> Result<(), String> { + // Tests parsing a single valid line where the measurement name begins with an underscore. + let lp = String::from("_readings,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800000"); + let expected_metric = Metric::new( + "_readings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![ + ("incline".to_string(), metric::FieldValue::I64(125)), + ("fuel_usage".to_string(), metric::FieldValue::F64(21.30)), + ], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_no_fields() -> Result<(), String> { + // Tests parsing a single invalid line where fields are missing. + let lp = String::from("readings,fleet=Alberta 1577836800000"); + + let output_metrics = parse_line_protocol(&lp); + + assert!(output_metrics.is_err()); + Ok(()) +} + +#[test] +fn test_parse_multiple_fields() -> Result<(), String> { + // Tests parsing a single valid line with multiple fields. + let lp = String::from("readings incline=125i,fuel_usage=21.30 1577836800000"); + + let expected_metric = Metric::new( + "readings".to_string(), + None, + vec![ + ("incline".to_string(), metric::FieldValue::I64(125)), + ("fuel_usage".to_string(), metric::FieldValue::F64(21.30)), + ], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[ignore] +#[test] +fn test_parse_multiple_measurements() -> Result<(), String> { + // Tests parsing a single invalid line with multiple measurement names. + let lp = String::from( + "readings,readings2,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800000", + ); + + let output_metrics = parse_line_protocol(&lp); + + assert!(output_metrics.is_err()); + Ok(()) +} + +#[test] +fn test_parse_no_timestamp() -> Result<(), String> { + // Tests parsing a single invalid line without a timestamp. + let lp = String::from("readings,fleet=Alberta incline=125i,fuel_usage=21.30"); + + let output_metrics = parse_line_protocol(&lp); + + assert!(output_metrics.is_err()); + Ok(()) +} + +#[test] +fn test_parse_non_unix_timestamp() -> Result<(), String> { + // Tests parsing a single invalid line with a non-unix timestamp. + let lp = + String::from("readings,fleet=Alberta incline=125i,fuel_usage=21.30 2020-01-01T00:00:00Z"); + + let output_metrics = parse_line_protocol(&lp); + + assert!(output_metrics.is_err()); + Ok(()) +} + +#[test] +fn test_parse_timestamp_with_quotes() -> Result<(), String> { + // Tests parsing a single invalid line with the timestamp in double quotes. + let lp = String::from("readings,fleet=Alberta incline=125i,fuel_usage=21.30 \"1577836800000\""); + + let output_metrics = parse_line_protocol(&lp); + assert!(output_metrics.is_err()); + Ok(()) +} + +#[test] +fn test_parse_no_whitespace() -> Result<(), String> { + // Tests parsing a single invalid line with no whitespace between components. + let lp = String::from("readings,fuel_usage=21.30,2020-01-01T00:00:00Z"); + + let output_metrics = parse_line_protocol(&lp); + + assert!(output_metrics.is_err()); + Ok(()) +} + +#[test] +fn test_parse_multiple_timestamps() -> Result<(), String> { + // Tests parsing a single invalid line with multiple timestamps. + let lp = String::from("readings incline=125i 1577836800000000000 1577836800000"); + + let output_metrics = parse_line_protocol(&lp); + assert!(output_metrics.is_err()); + Ok(()) +} + +#[test] +fn test_parse_batch() -> Result<(), String> { + // Tests parsing multiple valid lines with integer field values. + let lp = String::from( + "readings incline=125i 1577836800000 + readings incline=125i 1577836800000 + readings incline=125i 1577836800000 + readings incline=125i 1577836800000 + readings incline=125i 1577836800000 + readings incline=125i 1577836800000", + ); + + let expected_metric = Metric::new( + "readings".to_string(), + None, + vec![("incline".to_string(), metric::FieldValue::I64(125))], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + for metric in output_metrics { + assert!(metrics_are_equal(&metric, &expected_metric)); + } + Ok(()) +} + +#[test] +fn test_parse_emojis() -> Result<(), String> { + // Tests parsing a single valid line with emojis included in the measurement name, tag key, + // tag value, field key, and field value. + let lp = String::from("re😎dings,fl☕️et=🤙 in🤠line=\"😀\" 1577836800000"); + + let expected_metric = Metric::new( + "re😎dings".to_string(), + Some(vec![("fl☕️et".to_string(), "🤙".to_string())]), + vec![( + "in🤠line".to_string(), + metric::FieldValue::String("😀".to_string()), + )], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_escaped_comma() -> Result<(), String> { + // Tests parsing a single valid line with escaped commas in the measurement name, tag key, + // tag value, and field key. + let lp = String::from(r"\,readings,fleet\,=A\,lberta inc\,line=125i 1577836800000"); + + let expected_metric = Metric::new( + ",readings".to_string(), + Some(vec![("fleet,".to_string(), "A,lberta".to_string())]), + vec![("inc,line".to_string(), metric::FieldValue::I64(125))], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_escaped_equals() -> Result<(), String> { + // Tests parsing a single valid line with escaped equals signs in the tag key, tag value, + // and field key. + let lp = String::from(r"readings,fleet\==A\=lberta inc\=line=125i 1577836800000"); + + let expected_metric = Metric::new( + "readings".to_string(), + Some(vec![("fleet=".to_string(), "A=lberta".to_string())]), + vec![(r"inc=line".to_string(), metric::FieldValue::I64(125))], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_unescaped_equals_measurement() -> Result<(), String> { + // Tests parsing a single valid line with an unescaped equals sign in the measurement name. + let lp = String::from(r"rea=dings,fleet=Alberta incline=125i 1577836800000"); + + let expected_metric = Metric::new( + "rea=dings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![(r"incline".to_string(), metric::FieldValue::I64(125))], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_escaped_equals_measurement() -> Result<(), String> { + // Tests parsing a single valid line with an escaped equals sign in the measurement name. + let lp = String::from(r"rea\=dings,fleet=Alberta incline=125i 1577836800000"); + + let expected_metric = Metric::new( + "rea\\=dings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![(r"incline".to_string(), metric::FieldValue::I64(125))], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_escaped_space() -> Result<(), String> { + // Tests parsing a single valid line with an escaped space in the tag key, tag value, + // and field key. + let lp = String::from(r"readings,fleet\ =A\ lberta inc\ line=125i 1577836800000"); + + let expected_metric = Metric::new( + "readings".to_string(), + Some(vec![("fleet ".to_string(), "A lberta".to_string())]), + vec![(r"inc line".to_string(), metric::FieldValue::I64(125))], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_measurement_escaped_space() -> Result<(), String> { + // Tests parsing a single valid line with an escaped space in measurement name. + let lp = String::from(r"read\ ings,fleet=Alberta incline=125i 1577836800000"); + + let expected_metric = Metric::new( + "read ings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![(r"incline".to_string(), metric::FieldValue::I64(125))], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_escaped_double_quote_field_value() -> Result<(), String> { + // Tests parsing a single valid line with escaped quotes in the field value. + let lp = String::from("readings,fleet=Alberta incline=\"\\\"test\\\"\" 1577836800000"); + + let expected_metric = Metric::new( + "readings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![( + r"incline".to_string(), + metric::FieldValue::String(String::from("\"test\"")), + )], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_non_escaped_double_quote_field_value() -> Result<(), String> { + // Tests parsing a single invalid line with unescaped quotes in the field value. + let lp = String::from("readings,fleet=Alberta incline=\"\"test\"\" 1577836800000"); + + let output_metrics = parse_line_protocol(&lp); + + assert!(output_metrics.is_err()); + Ok(()) +} + +#[test] +fn test_parse_escaped_backslash_field_value() -> Result<(), String> { + // Tests parsing a single valid line with escaped backslashes in the field value. + let lp = String::from("readings,fleet=Alberta incline=\"\\\\test\\\\\" 1577836800000"); + + let expected_metric = Metric::new( + "readings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![( + r"incline".to_string(), + metric::FieldValue::String(String::from("\\test\\")), + )], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_escaped_backslash_not_field_value() -> Result<(), String> { + // Tests parsing a single valid line with escaped backslashes in the measurement name, tag key, + // tag value, and field key. + let lp = String::from("read\\\\ings,fl\\\\eet=Al\\\\berta inc\\\\line=125i 1577836800000"); + + let expected_metric = Metric::new( + "read\\ings".to_string(), + Some(vec![("fl\\eet".to_string(), "Al\\berta".to_string())]), + vec![("inc\\line".to_string(), metric::FieldValue::I64(125))], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_single_point_single_comment() -> Result<(), String> { + // Tests parsing a single valid line with one comment included. + let lp = String::from( + "# This is a comment + readings,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800000", + ); + + let expected_metric = Metric::new( + "readings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![ + ("incline".to_string(), metric::FieldValue::I64(125)), + ("fuel_usage".to_string(), metric::FieldValue::F64(21.30)), + ], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_single_point_multiple_comments() -> Result<(), String> { + // Tests parsing a single valid line with two comments included. + let lp = String::from( + "# This is a comment + # This is another comment + readings,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800000", + ); + + let expected_metric = Metric::new( + "readings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![ + ("incline".to_string(), metric::FieldValue::I64(125)), + ("fuel_usage".to_string(), metric::FieldValue::F64(21.30)), + ], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + assert!(metrics_are_equal(&output_metrics[0], &expected_metric)); + Ok(()) +} + +#[test] +fn test_parse_multiple_points_single_comment() -> Result<(), String> { + // Tests parsing two valid lines with one comment included. + let lp = String::from( + "# This is a comment + readings,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800000 + readings,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800000", + ); + + let expected_metric = Metric::new( + "readings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![ + ("incline".to_string(), metric::FieldValue::I64(125)), + ("fuel_usage".to_string(), metric::FieldValue::F64(21.30)), + ], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + for metric in output_metrics { + assert!(metrics_are_equal(&metric, &expected_metric)); + } + Ok(()) +} + +#[test] +fn test_parse_multiple_points_multiple_comments() -> Result<(), String> { + // Tests parsing two valid lines with two comments included. + let lp = String::from( + "# This is a comment + # This is another comment + readings,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800000 + readings,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800000", + ); + + let expected_metric = Metric::new( + "readings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![ + ("incline".to_string(), metric::FieldValue::I64(125)), + ("fuel_usage".to_string(), metric::FieldValue::F64(21.30)), + ], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + for metric in output_metrics { + assert!(metrics_are_equal(&metric, &expected_metric)); + } + Ok(()) +} + +#[test] +fn test_parse_nanoseconds_timestamp() -> Result<(), String> { + // Tests parsing one valid line with a nanosecond timestamp. + let lp = + String::from("readings,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800000000000"); + + let expected_metric = Metric::new( + "readings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![ + ("incline".to_string(), metric::FieldValue::I64(125)), + ("fuel_usage".to_string(), metric::FieldValue::F64(21.30)), + ], + 1577836800000000000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + for metric in output_metrics { + assert!(metrics_are_equal(&metric, &expected_metric)); + } + Ok(()) +} + +#[test] +fn test_parse_microseconds_timestamp() -> Result<(), String> { + // Tests parsing one valid line with a microsecond timestamp. + let lp = String::from("readings,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800000000"); + + let expected_metric = Metric::new( + "readings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![ + ("incline".to_string(), metric::FieldValue::I64(125)), + ("fuel_usage".to_string(), metric::FieldValue::F64(21.30)), + ], + 1577836800000000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + for metric in output_metrics { + assert!(metrics_are_equal(&metric, &expected_metric)); + } + Ok(()) +} + +#[test] +fn test_parse_milliseconds_timestamp() -> Result<(), String> { + // Tests parsing one valid line with a millisecond timestamp. + let lp = String::from("readings,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800000"); + + let expected_metric = Metric::new( + "readings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![ + ("incline".to_string(), metric::FieldValue::I64(125)), + ("fuel_usage".to_string(), metric::FieldValue::F64(21.30)), + ], + 1577836800000, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + for metric in output_metrics { + assert!(metrics_are_equal(&metric, &expected_metric)); + } + Ok(()) +} + +#[test] +fn test_parse_seconds_timestamp() -> Result<(), String> { + // Tests parsing one valid line with a second timestamp. + let lp = String::from("readings,fleet=Alberta incline=125i,fuel_usage=21.30 1577836800"); + + let expected_metric = Metric::new( + "readings".to_string(), + Some(vec![("fleet".to_string(), "Alberta".to_string())]), + vec![ + ("incline".to_string(), metric::FieldValue::I64(125)), + ("fuel_usage".to_string(), metric::FieldValue::F64(21.30)), + ], + 1577836800, + ); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + for metric in output_metrics { + assert!(metrics_are_equal(&metric, &expected_metric)); + } + Ok(()) +} + +#[test] +fn test_parse_empty() -> Result<(), String> { + // Tests parsing empty line protocol. + let lp = String::new(); + + let output_metrics = parse_line_protocol(&lp).expect("Failed to parse line protocol"); + + // Should return an empty Vec. + assert!(output_metrics.is_empty()); + Ok(()) +} diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/main.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/main.rs new file mode 100644 index 00000000..aace484a --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/main.rs @@ -0,0 +1,16 @@ +use influxdb_timestream_connector::{ + lambda_handler, records_builder::validate_env_variables, timestream_utils::get_connection, +}; +use lambda_http::{run, service_fn, tracing, Error as lambda_error, Request}; + +#[tokio::main] +async fn main() -> Result<(), lambda_error> { + validate_env_variables()?; + let region = std::env::var("region")?; + let timestream_client = get_connection(®ion).await?; + tracing::init_default_subscriber(); + run(service_fn(|event: Request| { + lambda_handler(×tream_client, event) + })) + .await +} diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/metric.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/metric.rs new file mode 100644 index 00000000..a51f7c63 --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/metric.rs @@ -0,0 +1,60 @@ +#[derive(Debug)] +pub struct Metric { + name: String, + tags: Option>, + fields: Vec<(String, FieldValue)>, + timestamp: i64, +} + +#[derive(Debug, PartialEq)] +pub enum FieldValue { + Boolean(bool), + I64(i64), + U64(u64), + F64(f64), + String(String), +} + +impl std::fmt::Display for FieldValue { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + FieldValue::Boolean(v) => v.fmt(f), + FieldValue::I64(v) => v.fmt(f), + FieldValue::U64(v) => v.fmt(f), + FieldValue::F64(v) => v.fmt(f), + FieldValue::String(v) => v.fmt(f), + } + } +} + +impl Metric { + pub fn new( + name: String, + tags: Option>, + fields: Vec<(String, FieldValue)>, + timestamp: i64, + ) -> Self { + Metric { + name, + tags, + fields, + timestamp, + } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn tags(&self) -> &Option> { + &self.tags + } + + pub fn fields(&self) -> &Vec<(String, FieldValue)> { + &self.fields + } + + pub fn timestamp(&self) -> i64 { + self.timestamp + } +} diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs new file mode 100644 index 00000000..a257ff29 --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs @@ -0,0 +1,136 @@ +use crate::metric::Metric; +use anyhow::{anyhow, Error}; +use aws_sdk_timestreamwrite as timestream_write; +use std::collections::HashMap; + +mod multi_table_multi_measure_builder; + +pub enum SchemaType { + MultiTableMultiMeasure(String), +} + +impl std::fmt::Display for SchemaType { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + SchemaType::MultiTableMultiMeasure(v) => v.fmt(f), + } + } +} + +pub fn get_builder(schema: SchemaType) -> impl BuildRecords { + // Currently only supported schema is multi-table multi-measure + multi_table_multi_measure_builder::MultiTableMultiMeasureBuilder { + measure_name: schema.to_string(), + } +} + +pub fn build_records( + records_builder: &impl BuildRecords, + metrics: &[Metric], + precision: ×tream_write::types::TimeUnit, +) -> Result>, Error> { + records_builder.build_records(metrics, precision) +} + +pub struct TableConfig { + pub mag_store_retention_period: i64, + pub mem_store_retention_period: i64, + pub enable_mag_store_writes: bool, +} + +pub fn get_table_config() -> Result { + // Get the populated table_config struct + + Ok(TableConfig { + mag_store_retention_period: std::env::var("mag_store_retention_period")?.parse()?, + mem_store_retention_period: std::env::var("mem_store_retention_period")?.parse()?, + enable_mag_store_writes: matches!( + std::env::var("enable_mag_store_writes")? + .to_lowercase() + .as_str(), + "true" | "t" | "1" + ), + }) +} + +pub fn table_creation_enabled() -> Result { + // Convert the env var table_creation_enabled to bool + + match std::env::var("enable_table_creation") { + Ok(enabled) => Ok(env_var_to_bool(enabled)), + Err(_) => Err(anyhow!( + "enable_table_creation environment variable is not defined" + )), + } +} + +pub fn database_creation_enabled() -> Result { + // Convert the env var database_creation_enabled to bool + + match std::env::var("enable_database_creation") { + Ok(enabled) => Ok(env_var_to_bool(enabled)), + Err(_) => Err(anyhow!( + "enable_database_creation environment variable is not defined" + )), + } +} + +pub fn env_var_to_bool(env_var: String) -> bool { + // Convert the env var to bool + + matches!(env_var.as_str(), "true" | "t" | "1") +} + +pub fn validate_env_variables() -> Result<(), Error> { + // Validate environment variables for all schema types + + if std::env::var("region").is_err() { + return Err(anyhow!("region environment variable is not defined")); + } + if std::env::var("database_name").is_err() { + return Err(anyhow!("database_name environment variable is not defined")); + } + if std::env::var("enable_database_creation").is_err() { + return Err(anyhow!( + "enable_database_creation environment variable is not defined" + )); + } + let enable_table_creation = std::env::var("enable_table_creation"); + + if enable_table_creation.is_err() { + return Err(anyhow!( + "enable_table_creation environment variable is not defined" + )); + } + + if env_var_to_bool(enable_table_creation?) { + if std::env::var("enable_mag_store_writes").is_err() { + return Err(anyhow!( + "enable_mag_store_writes environment variable is not defined" + )); + } + if std::env::var("mag_store_retention_period").is_err() { + return Err(anyhow!( + "mag_store_retention_period environment variable is not defined" + )); + } + if std::env::var("mem_store_retention_period").is_err() { + return Err(anyhow!( + "mem_store_retention_period environment variable is not defined" + )); + } + } + + Ok(()) +} + +pub trait BuildRecords { + fn build_records( + &self, + metrics: &[Metric], + precision: ×tream_write::types::TimeUnit, + ) -> Result>, Error>; +} + +#[cfg(test)] +pub mod tests; diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_table_multi_measure_builder.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_table_multi_measure_builder.rs new file mode 100644 index 00000000..14062683 --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_table_multi_measure_builder.rs @@ -0,0 +1,114 @@ +use super::{validate_env_variables, BuildRecords}; +use crate::metric::{FieldValue, Metric}; +use anyhow::{anyhow, Error, Result}; +use aws_sdk_timestreamwrite as timestream_write; +use std::collections::HashMap; + +pub struct MultiTableMultiMeasureBuilder { + pub measure_name: String, +} + +impl BuildRecords for MultiTableMultiMeasureBuilder { + // trait implementation to support multi-measure multi-table schema with Timestream + + fn build_records( + &self, + metrics: &[Metric], + precision: ×tream_write::types::TimeUnit, + ) -> Result>, Error> { + validate_env_variables()?; + validate_multi_measure_env_variables()?; + build_multi_measure_records(metrics, &self.measure_name, precision) + } +} + +fn validate_multi_measure_env_variables() -> Result<(), Error> { + // Validate environment variables for multi-measure schema types + + if std::env::var("measure_name_for_multi_measure_records").is_err() { + return Err(anyhow!( + "measure_name_for_multi_measure_records environment variable is not defined" + )); + } + + Ok(()) +} + +fn build_multi_measure_records( + metrics: &[Metric], + measure_name: &str, + precision: ×tream_write::types::TimeUnit, +) -> Result>, Error> { + // Builds multi-measure multi-table records hashmap + + let mut multi_table_batch: HashMap> = + HashMap::new(); + for metric in metrics.iter() { + let new_record = metric_to_timestream_record(measure_name, metric, precision)?; + let table_name = metric.name(); + if let Some(record_vec) = multi_table_batch.get_mut(table_name) { + record_vec.push(new_record); + } else { + multi_table_batch.insert(table_name.to_string(), vec![new_record]); + } + } + + Ok(multi_table_batch) +} + +pub fn metric_to_timestream_record( + measure_name: &str, + metric: &Metric, + precision: ×tream_write::types::TimeUnit, +) -> Result { + // Converts the metric struct to a timestream multi-measure record + + let mut dimensions: Vec = Vec::new(); + for tag in metric.tags().iter().flatten() { + dimensions.push( + timestream_write::types::Dimension::builder() + .name(tag.0.to_owned()) + .value(tag.1.to_owned()) + .build() + .expect("Failed to build dimension"), + ) + } + + let mut measure_values: Vec = Vec::new(); + for field in metric.fields() { + let measure_type = get_timestream_measure_type(&field.1)?; + measure_values.push( + timestream_write::types::MeasureValue::builder() + .name(field.0.to_owned()) + .value(field.1.to_string()) + .r#type(measure_type) + .build() + .expect("Failed to build measure"), + ); + } + + let new_record = timestream_write::types::Record::builder() + .measure_name(measure_name) + .set_measure_values(Some(measure_values)) + .set_measure_value_type(Some(timestream_write::types::MeasureValueType::Multi)) + .set_time_unit(Some(precision.clone())) + .time(metric.timestamp().to_string()) + .set_dimensions(Some(dimensions)) + .build(); + + Ok(new_record) +} + +pub fn get_timestream_measure_type( + field_value: &FieldValue, +) -> Result { + // Converts a metric struct type to a timestream measure value type + + match field_value { + FieldValue::Boolean(_) => Ok(timestream_write::types::MeasureValueType::Boolean), + FieldValue::I64(_) => Ok(timestream_write::types::MeasureValueType::Bigint), + FieldValue::U64(_) => Ok(timestream_write::types::MeasureValueType::Bigint), + FieldValue::F64(_) => Ok(timestream_write::types::MeasureValueType::Double), + FieldValue::String(_) => Ok(timestream_write::types::MeasureValueType::Varchar), + } +} diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/tests.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/tests.rs new file mode 100644 index 00000000..9a9d0015 --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/tests.rs @@ -0,0 +1,391 @@ +use super::build_records; +use crate::metric::{FieldValue, Metric}; +use anyhow::Error; +use aws_sdk_timestreamwrite as timestream_write; +use std::env; + +#[test] +fn test_mtmm_single_record() -> Result<(), Error> { + // Single measure for multi-measure record + + setup_minimal_env_vars(); + setup_multi_measure_env_vars(); + let multi_table_multi_measure_schema = + super::SchemaType::MultiTableMultiMeasure(String::from("influxdb-connector-measure")); + let multi_table_multi_measure_builder = super::get_builder(multi_table_multi_measure_schema); + let metrics = [Metric::new( + "readings".to_string(), + vec![(String::from("goal"), String::from("baseline"))].into(), + vec![(String::from("incline"), FieldValue::I64(125))], + 1577836800000, + )]; + + let records = build_records( + &multi_table_multi_measure_builder, + &metrics, + ×tream_write::types::TimeUnit::Nanoseconds, + )?; + assert_eq!(records.len(), 1); + let first_record = records + .get("readings") + .expect("Failed to unwrap") + .first() + .expect("Failed to unwrap"); + assert_eq!(first_record.time, Some(String::from("1577836800000"))); + + assert_eq!( + first_record.measure_name(), + Some("influxdb-connector-measure") + ); + assert_eq!( + first_record.measure_value_type(), + Some(×tream_write::types::MeasureValueType::Multi) + ); + assert!(first_record.measure_values().contains( + ×tream_write::types::MeasureValue::builder() + .name(String::from("incline")) + .value(String::from("125")) + .r#type(timestream_write::types::MeasureValueType::Bigint) + .build() + .expect("Failed to build measure") + )); + assert!(first_record.dimensions().contains( + ×tream_write::types::Dimension::builder() + .name(String::from("goal")) + .value(String::from("baseline")) + .build() + .expect("Failed to build dimension") + )); + + Ok(()) +} + +#[test] +fn test_mtmm_single_destination() -> Result<(), Error> { + // Dataset all going to same table + + setup_minimal_env_vars(); + setup_multi_measure_env_vars(); + let multi_table_multi_measure_schema = + super::SchemaType::MultiTableMultiMeasure(String::from("influxdb-connector-measure")); + let multi_table_multi_measure_builder = super::get_builder(multi_table_multi_measure_schema); + let metrics = [ + Metric::new( + "readings".to_string(), + vec![(String::from("goal"), String::from("baseline"))].into(), + vec![(String::from("incline"), FieldValue::I64(125))], + 1577836800000, + ), + Metric::new( + "readings".to_string(), + vec![(String::from("goal"), String::from("baseline"))].into(), + vec![(String::from("incline"), FieldValue::I64(150))], + 1577836900032, + ), + ]; + + let records = build_records( + &multi_table_multi_measure_builder, + &metrics, + ×tream_write::types::TimeUnit::Nanoseconds, + )?; + assert_eq!(records.len(), 1); + let readings = records.get("readings").expect("Failed to unwrap"); + let first_record = &readings[0]; + let second_record = &readings[1]; + assert_eq!(first_record.time, Some(String::from("1577836800000"))); + assert_eq!(second_record.time, Some(String::from("1577836900032"))); + + assert_eq!( + first_record.measure_name(), + Some("influxdb-connector-measure") + ); + assert_eq!( + second_record.measure_name(), + Some("influxdb-connector-measure") + ); + assert_eq!( + first_record.measure_value_type(), + Some(×tream_write::types::MeasureValueType::Multi) + ); + assert_eq!( + second_record.measure_value_type(), + Some(×tream_write::types::MeasureValueType::Multi) + ); + assert!(first_record.measure_values().contains( + ×tream_write::types::MeasureValue::builder() + .name(String::from("incline")) + .value(String::from("125")) + .r#type(timestream_write::types::MeasureValueType::Bigint) + .build() + .expect("Failed to build measure") + )); + assert!(first_record.dimensions().contains( + ×tream_write::types::Dimension::builder() + .name(String::from("goal")) + .value(String::from("baseline")) + .build() + .expect("Failed to build dimension") + )); + assert!(second_record.measure_values().contains( + ×tream_write::types::MeasureValue::builder() + .name(String::from("incline")) + .value(String::from("150")) + .r#type(timestream_write::types::MeasureValueType::Bigint) + .build() + .expect("Failed to build measure") + )); + assert!(second_record.dimensions().contains( + ×tream_write::types::Dimension::builder() + .name(String::from("goal")) + .value(String::from("baseline")) + .build() + .expect("Failed to build dimension") + )); + + Ok(()) +} + +#[test] +fn test_mtmm_multi_record() -> Result<(), Error> { + // Dataset going to multiple table destinations + + setup_minimal_env_vars(); + setup_multi_measure_env_vars(); + let multi_table_multi_measure_schema = + super::SchemaType::MultiTableMultiMeasure(String::from("influxdb-connector-measure")); + let multi_table_multi_measure_builder = super::get_builder(multi_table_multi_measure_schema); + let metrics = [ + Metric::new( + "readings".to_string(), + vec![(String::from("goal"), String::from("baseline"))].into(), + vec![(String::from("incline"), FieldValue::I64(125))], + 1577836800000, + ), + Metric::new( + "velocity".to_string(), + vec![(String::from("goal"), String::from("baseline"))].into(), + vec![(String::from("km/h"), FieldValue::F64(4.6))], + 1577836911132, + ), + ]; + + let records = build_records( + &multi_table_multi_measure_builder, + &metrics, + ×tream_write::types::TimeUnit::Nanoseconds, + )?; + assert_eq!(records.len(), 2); + let readings = records + .get("readings") + .expect("Failed to unwrap") + .first() + .expect("Failed to unwrap"); + let velocity = records + .get("velocity") + .expect("Failed to unwrap") + .first() + .expect("Failed to unwrap"); + assert_eq!(readings.time, Some(String::from("1577836800000"))); + assert_eq!(velocity.time, Some(String::from("1577836911132"))); + + assert_eq!(readings.measure_name(), Some("influxdb-connector-measure")); + assert_eq!(velocity.measure_name(), Some("influxdb-connector-measure")); + assert_eq!( + readings.measure_value_type(), + Some(×tream_write::types::MeasureValueType::Multi) + ); + assert_eq!( + velocity.measure_value_type(), + Some(×tream_write::types::MeasureValueType::Multi) + ); + assert!(readings.measure_values().contains( + ×tream_write::types::MeasureValue::builder() + .name(String::from("incline")) + .value(String::from("125")) + .r#type(timestream_write::types::MeasureValueType::Bigint) + .build() + .expect("Failed to build measure") + )); + assert!(readings.dimensions().contains( + ×tream_write::types::Dimension::builder() + .name(String::from("goal")) + .value(String::from("baseline")) + .build() + .expect("Failed to build dimension") + )); + assert!(velocity.measure_values().contains( + ×tream_write::types::MeasureValue::builder() + .name(String::from("km/h")) + .value(String::from("4.6")) + .r#type(timestream_write::types::MeasureValueType::Double) + .build() + .expect("Failed to build measure") + )); + assert!(velocity.dimensions().contains( + ×tream_write::types::Dimension::builder() + .name(String::from("goal")) + .value(String::from("baseline")) + .build() + .expect("Failed to build dimension") + )); + + Ok(()) +} + +#[test] +fn test_mtmm_empty_dimensions() -> Result<(), Error> { + // Dataset with empty dimensions + + setup_minimal_env_vars(); + setup_multi_measure_env_vars(); + let multi_table_multi_measure_schema = + super::SchemaType::MultiTableMultiMeasure(String::from("influxdb-connector-measure")); + let multi_table_multi_measure_builder = super::get_builder(multi_table_multi_measure_schema); + let metrics = [Metric::new( + "readings".to_string(), + None, + vec![(String::from("incline"), FieldValue::I64(125))], + 1577836800000, + )]; + + let records = build_records( + &multi_table_multi_measure_builder, + &metrics, + ×tream_write::types::TimeUnit::Nanoseconds, + )?; + assert_eq!(records.len(), 1); + let readings = records.get("readings").expect("Failed to unwrap"); + let first_record = &readings[0]; + assert_eq!(first_record.time, Some(String::from("1577836800000"))); + + assert_eq!( + first_record.measure_name(), + Some("influxdb-connector-measure") + ); + assert_eq!( + first_record.measure_value_type(), + Some(×tream_write::types::MeasureValueType::Multi) + ); + assert!(first_record.measure_values().contains( + ×tream_write::types::MeasureValue::builder() + .name(String::from("incline")) + .value(String::from("125")) + .r#type(timestream_write::types::MeasureValueType::Bigint) + .build() + .expect("Failed to build measure") + )); + assert!(first_record.dimensions().is_empty()); + + Ok(()) +} + +#[test] +fn test_mtmm_varying_timestamp_records() -> Result<(), Error> { + // Varying timestamp parsing + + setup_minimal_env_vars(); + setup_multi_measure_env_vars(); + let multi_table_multi_measure_schema = + super::SchemaType::MultiTableMultiMeasure(String::from("influxdb-connector-measure")); + let multi_table_multi_measure_builder = super::get_builder(multi_table_multi_measure_schema); + let metrics = [ + Metric::new( + "readings".to_string(), + vec![(String::from("goal"), String::from("baseline"))].into(), + vec![(String::from("incline"), FieldValue::I64(125))], + 1577836866658, + ), + Metric::new( + "velocity".to_string(), + vec![(String::from("goal"), String::from("baseline"))].into(), + vec![(String::from("km/h"), FieldValue::F64(4.6))], + 1577836911132, + ), + ]; + + let records = build_records( + &multi_table_multi_measure_builder, + &metrics, + ×tream_write::types::TimeUnit::Nanoseconds, + )?; + assert_eq!(records.len(), 2); + + let first_record = records + .get("readings") + .expect("Failed to unwrap") + .first() + .expect("Failed to unwrap"); + + assert_eq!(first_record.time, Some(String::from("1577836866658"))); + assert_eq!( + first_record.measure_name(), + Some("influxdb-connector-measure") + ); + assert_eq!( + first_record.measure_value_type(), + Some(×tream_write::types::MeasureValueType::Multi) + ); + assert!(first_record.measure_values().contains( + ×tream_write::types::MeasureValue::builder() + .name(String::from("incline")) + .value(String::from("125")) + .r#type(timestream_write::types::MeasureValueType::Bigint) + .build() + .expect("Failed to build measure") + )); + assert!(first_record.dimensions().contains( + ×tream_write::types::Dimension::builder() + .name(String::from("goal")) + .value(String::from("baseline")) + .build() + .expect("Failed to build dimension") + )); + + let second_record = records + .get("velocity") + .expect("Failed to unwrap") + .first() + .expect("Failed to unwrap"); + + assert_eq!(second_record.time, Some(String::from("1577836911132"))); + assert_eq!( + second_record.measure_name(), + Some("influxdb-connector-measure") + ); + assert_eq!( + second_record.measure_value_type(), + Some(×tream_write::types::MeasureValueType::Multi) + ); + + assert!(second_record.measure_values().contains( + ×tream_write::types::MeasureValue::builder() + .name(String::from("km/h")) + .value(String::from("4.6")) + .r#type(timestream_write::types::MeasureValueType::Double) + .build() + .expect("Failed to build measure") + )); + + assert!(second_record.dimensions().contains( + ×tream_write::types::Dimension::builder() + .name(String::from("goal")) + .value(String::from("baseline")) + .build() + .expect("Failed to build dimension") + )); + + Ok(()) +} + +fn setup_multi_measure_env_vars() { + env::set_var("measure_name_for_multi_measure_records", "influxdb-measure"); +} + +fn setup_minimal_env_vars() { + env::set_var("enable_table_creation", "false"); + env::set_var("region", "us-west-2"); + env::set_var("database_name", "test-database"); + env::set_var("enable_database_creation", "false"); + env::set_var("enable_mag_store_writes", "false"); +} diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs new file mode 100644 index 00000000..63c810f3 --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs @@ -0,0 +1,165 @@ +use super::records_builder::TableConfig; +use anyhow::{anyhow, Error, Result}; +use aws_sdk_timestreamwrite as timestream_write; +use aws_types::region::Region; +use std::io::Write; + +pub async fn get_connection( + region: &str, +) -> Result { + // Get a connection to Timestream + + let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .region(Region::new(region.to_owned())) + .load() + .await; + let (client, reload) = timestream_write::Client::new(&config) + .with_endpoint_discovery_enabled() + .await + .expect("Failed to get the write client connection with Timestream"); + + tokio::task::spawn(reload.reload_task()); + println!("Initialized connection to Timestream in region {}", region); + Ok(client) +} + +pub async fn create_database( + client: ×tream_write::Client, + database_name: &str, +) -> Result<(), timestream_write::Error> { + // Create a new Timestream database + + println!("Creating new database {}", database_name); + client + .create_database() + .set_database_name(Some(database_name.to_owned())) + .send() + .await?; + + Ok(()) +} + +pub async fn create_table( + client: ×tream_write::Client, + database_name: &str, + table_name: &str, + table_config: TableConfig, +) -> Result<(), timestream_write::Error> { + // Create a new Timestream table + + println!( + "Creating new table {} for database {}", + table_name, database_name + ); + let retention_properties = timestream_write::types::RetentionProperties::builder() + .set_magnetic_store_retention_period_in_days(Some(table_config.mag_store_retention_period)) + .set_memory_store_retention_period_in_hours(Some(table_config.mem_store_retention_period)) + .build()?; + + let magnetic_store_properties = + timestream_write::types::MagneticStoreWriteProperties::builder() + .set_enable_magnetic_store_writes(Some(table_config.enable_mag_store_writes)) + .build()?; + + client + .create_table() + .set_table_name(Some(table_name.to_owned())) + .set_database_name(Some(database_name.to_owned())) + .set_retention_properties(Some(retention_properties)) + .set_magnetic_store_write_properties(Some(magnetic_store_properties)) + .send() + .await?; + + Ok(()) +} + +pub async fn table_exists( + client: ×tream_write::Client, + database_name: &str, + table_name: &str, +) -> Result { + // Check if table already exists + + match client + .describe_table() + .table_name(table_name) + .database_name(database_name) + .send() + .await + { + Ok(_) => Ok(true), + Err(error) => match error + .as_service_error() + .map(|e| e.is_resource_not_found_exception()) + { + Some(true) => Ok(false), + _ => Err(anyhow!(error)), + }, + } +} + +pub async fn database_exists( + client: ×tream_write::Client, + database_name: &str, +) -> Result { + // Check if database already exists + + match client + .describe_database() + .database_name(database_name) + .send() + .await + { + Ok(_) => Ok(true), + Err(error) => match error + .as_service_error() + .map(|e| e.is_resource_not_found_exception()) + { + Some(true) => Ok(false), + _ => Err(anyhow!(error)), + }, + } +} + +pub async fn ingest_records( + client: ×tream_write::Client, + database_name: &str, + table_name: &str, + records: &mut [timestream_write::types::Record], +) -> Result<(), Error> { + // Ingest records to Timestream in batches of 100 (Max supported Timestream batch size) + + let mut records_ingested: usize = 0; + const MAX_TIMESTREAM_BATCH_SIZE: usize = 100; + + let mut records_chunked: Vec> = records + .chunks(MAX_TIMESTREAM_BATCH_SIZE) + .map(|sub_records| sub_records.to_vec()) + .collect(); + for chunk in records_chunked.iter_mut() { + records_ingested += chunk.len(); + match client + .write_records() + .database_name(database_name) + .table_name(table_name) + .set_records(Some(std::mem::take(chunk))) + .send() + .await + { + Ok(_) => { + println!("{} records ingested", records_ingested); + std::io::stdout().flush()?; + } + Err(error) => { + println!("SdkError: {:?}", error.raw_response().unwrap()); + return Err(anyhow!(error)); + } + } + } + println!( + "{} records ingested total for table {} in database {}", + records_ingested, table_name, database_name + ); + + Ok(()) +} diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs b/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs new file mode 100644 index 00000000..656e1390 --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs @@ -0,0 +1,1171 @@ +use anyhow::Error; +use aws_credential_types::Credentials; +use aws_sdk_timestreamwrite as timestream_write; +use aws_types::region::Region; +use lambda_http::http::StatusCode; +use lambda_http::{http::Request, IntoResponse}; +use lambda_http::{Body, RequestExt}; +use rand::{distributions::uniform::SampleUniform, distributions::Alphanumeric, Rng}; +use std::collections::HashMap; +use std::{env, thread, time}; + +static DATABASE_NAME: &str = "influxdb_timestream_connector_integ_db"; +static REGION: &str = "us-west-2"; +static MAX_TIMESTREAM_TABLE_NAME_LENGTH: usize = 256; + +// A batch of resources to be deleted at the end of a test. +struct CleanupBatch { + client: timestream_write::Client, + database_name: String, + table_names_to_delete: Vec, +} + +impl CleanupBatch { + pub fn new( + client: timestream_write::Client, + database_name: String, + table_names_to_delete: Vec, + ) -> CleanupBatch { + CleanupBatch { + client, + database_name, + table_names_to_delete, + } + } + + async fn cleanup(&mut self) { + for table_name_to_delete in self.table_names_to_delete.iter() { + println!( + "Deleting table {} in database {}", + table_name_to_delete, self.database_name + ); + thread::sleep(time::Duration::from_secs( + influxdb_timestream_connector::TIMESTREAM_API_WAIT_SECONDS, + )); + let result = self + .client + .delete_table() + .database_name(&self.database_name) + .table_name(table_name_to_delete) + .send() + .await; + match result { + Ok(_) => (), + + Err(error) => { + println!( + "Table deletion failed for table {}: {:?}", + table_name_to_delete, + error.raw_response() + ); + } + } + } + } +} + +fn set_environment_variables() { + env::set_var("database_name", DATABASE_NAME); + env::set_var("enable_database_creation", "true"); + env::set_var("enable_table_creation", "true"); + env::set_var("enable_mag_store_writes", "true"); + // A value of 30,000 allows single-digit timestamps to be ingested. + env::set_var("mag_store_retention_period", "30000"); + env::set_var("mem_store_retention_period", "12"); + env::set_var("region", REGION); + env::set_var( + "measure_name_for_multi_measure_records", + "test_measure_name", + ); +} + +fn random_string(n: usize) -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(n) + .map(char::from) + .collect() +} + +fn random_number(low: T, high: T) -> T { + rand::thread_rng().gen_range(low, high) +} + +// These integration tests use the InfluxDB Timestream connector as a library +// instead of deploying the connector as a lambda and then making +// requests to the connector. Each test builds a lambda_http::http::Request and +// passes it to the connector's lambda_handler function. This means integration +// testing has minimal overhead. + +#[tokio::test] +async fn test_mtmm_basic() -> Result<(), Error> { + // Tests ingesting a single point. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_multiple_timestamps() -> Result<(), Error> { + // Tests ingesting a single point with two timestamps. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {} {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis(), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() != StatusCode::OK); + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_many_tags_many_fields() -> Result<(), Error> { + // Tests ingesting a single point with 50 tags and 50 fields. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let mut point = format!("{},", lp_measurement_name); + + for i in 0..50 { + point.push_str(&format!("tag{}={}", i, random_string(9))); + if i != 49 { + point.push(','); + } + } + point.push(' '); + for i in 0..50 { + point.push_str(&format!("field{}={}i", i, random_number(0, 100001))); + if i != 49 { + point.push(','); + } + } + point.push_str(&format!(" {}\n", chrono::Utc::now().timestamp_millis())); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_float() -> Result<(), Error> { + // Tests ingesting a single point with a float value for the field. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={} {}\n", + lp_measurement_name, + random_string(9), + random_number(0.0, 100001.0), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_string() -> Result<(), Error> { + // Tests ingesting a single point with a string value for the field. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1=\"{}\" {}\n", + lp_measurement_name, + random_string(9), + random_string(9), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_bool() -> Result<(), Error> { + // Tests ingesting a single point with a bool value for the field. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={} {}\n", + lp_measurement_name, + random_string(9), + true, + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_max_tag_length() -> Result<(), Error> { + // Tests ingesting a single point with a tag where the tag's key + // is 60, the maximum allowed dimension name length, and its value + // is 1988 characters long. The length of the tag key and tag value + // together amount to the maximum size for a dimension pair, 2 kilobytes. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},{}={} field1={}i {}\n", + lp_measurement_name, + random_string(60), + random_string(1988), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_beyond_max_tag_length() -> Result<(), Error> { + // Tests ingesting a single point with a tag where the tag's key + // is 60, the maximum allowed dimension name length, and its value + // is 1989 characters long. The length of the tag key and tag value + // together exceed the maximum size for a dimension pair, 2 kilobytes. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},{}={} field1={}i {}\n", + lp_measurement_name, + random_string(60), + random_string(1989), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() != StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_max_field_length() -> Result<(), Error> { + // Tests ingesting a single point with a field where the length + // of the field key is the maximum measure name, 256, and the length + // of the field value is the maximum measure value size, 2048. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},field1={} {}=\"{}\" {}\n", + lp_measurement_name, + random_string(9), + random_string(256), + random_string(2048), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_beyond_max_field_length() -> Result<(), Error> { + // Tests ingesting a single point with a field where the length + // of the field key is the maximum measure name, 256, and the length + // of the field value is beyond the maximum measure value size, 2048. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},field1={} {}=\"{}\" {}\n", + lp_measurement_name, + random_string(9), + random_string(256), + random_string(2049), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() != StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_max_unique_field_keys() -> Result<(), Error> { + // Tests ingesting a batch of points where the number of unique field keys + // in the batch equals the maximum number of unique measures for a single + // table, 1024. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let mut lp_batch = String::new(); + for i in 0..1024 { + let point = format!( + "{},tag1={} field{}={}i {}\n", + lp_measurement_name, + random_string(9), + i, + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + lp_batch.push_str(&point); + } + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(lp_batch))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_beyond_max_unique_field_keys() -> Result<(), Error> { + // Tests ingesting a batch of points where the number of unique field keys + // in the batch exceeds the maximum number of unique measures for a single + // table, 1024. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let mut lp_batch = String::new(); + for i in 0..1025 { + let point = format!( + "{},tag1={} field{}={}i {}\n", + lp_measurement_name, + random_string(9), + i, + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + lp_batch.push_str(&point); + } + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(lp_batch))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() != StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_max_unique_tag_keys() -> Result<(), Error> { + // Tests ingesting a batch of points where the number of unique tag keys + // in the batch equals the maximum number of unique dimensions for a single + // table, 128. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let mut lp_batch = String::new(); + for i in 0..128 { + let point = format!( + "{},tag{}={} field1={}i {}\n", + lp_measurement_name, + i, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + lp_batch.push_str(&point); + } + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(lp_batch))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_beyond_max_unique_tag_keys() -> Result<(), Error> { + // Tests ingesting a batch of points where the number of unique tag keys + // in the batch exceeds the maximum number of unique dimensions for a single + // table, 128. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let mut lp_batch = String::new(); + for i in 0..129 { + let point = format!( + "{},tag{}={} field1={}i {}\n", + lp_measurement_name, + i, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + lp_batch.push_str(&point); + } + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(lp_batch))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() != StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_max_table_name_length() -> Result<(), Error> { + // Tests ingesting a single point with measurement with length + // equal to the maximum number of bytes a Timestream table name can + // have. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = random_string(MAX_TIMESTREAM_TABLE_NAME_LENGTH); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_beyond_max_table_name_length() { + // Tests ingesting a single point with measurement with length + // exceeding the maximum number of bytes a Timestream table name can + // have. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = random_string(MAX_TIMESTREAM_TABLE_NAME_LENGTH + 1); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point)) + .expect("Failed to create request") + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await + .expect("Failed to send request to lambda handler") + .into_response() + .await; + + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() != StatusCode::OK); +} + +#[tokio::test] +async fn test_mtmm_nanosecond_precision() -> Result<(), Error> { + // Tests ingesting a single point with nanosecond precision. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now() + .timestamp_nanos_opt() + .expect("Failed to create nanosecond timestamp") + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ns".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_microsecond_precision() -> Result<(), Error> { + // Tests ingesting a single point with microsecond precision. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_micros() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "us".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_second_precision() -> Result<(), Error> { + // Tests ingesting a single point with second precision. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "s".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_no_precision() -> Result<(), Error> { + // Tests ingesting a single point without precision supplied. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + // Without a precision provided, the connector should assume the precision + // is nanoseconds. + chrono::offset::Utc::now() + .timestamp_nanos_opt() + .expect("Failed to create nanosecond timestamp") + ); + + let request = Request::builder() + .method("POST") + .body(Body::Text(point))?; + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_empty_point() -> Result<(), Error> { + // Tests ingesting an empty string. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let point = String::new(); + + let request = Request::builder() + .method("POST") + .body(Body::Text(point))?; + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + Ok(()) +} + +#[tokio::test] +pub async fn test_mtmm_small_timestamp() -> Result<(), Error> { + // Tests ingesting with a single-digit millisecond timestamp. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + 3 + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_5_measurements() -> Result<(), Error> { + // Tests ingesting a batch with five measurements. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let mut table_names_to_delete = Vec::::new(); + let lp_measurement_name = String::from("readings"); + + let mut lp_batch = String::new(); + for i in 0..5 { + let lp_measurement_name = format!("{lp_measurement_name}{i}").to_string(); + table_names_to_delete.push(lp_measurement_name.clone()); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::Utc::now().timestamp_millis() + ); + lp_batch.push_str(&point); + } + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(lp_batch))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), table_names_to_delete); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_100_measurements() -> Result<(), Error> { + // Tests ingesting a batch with 100 measurements. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let mut table_names_to_delete = Vec::::new(); + + let lp_measurement_name = String::from("readings"); + let mut lp_batch = String::new(); + for i in 0..100 { + let lp_measurement_name = format!("{lp_measurement_name}{i}").to_string(); + table_names_to_delete.push(lp_measurement_name.clone()); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::Utc::now().timestamp_millis() + ); + lp_batch.push_str(&point); + } + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(lp_batch))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), table_names_to_delete); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_5000_batch() -> Result<(), Error> { + // Tests ingesting a batch of 5000 points with a single measurement. + // 5000 is the recommended batch size for InfluxDB v2 OSS. + set_environment_variables(); + // Cleanup + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + let mut lp_batch = String::new(); + + for _ in 0..5000 { + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + lp_batch.push_str(&point); + } + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(lp_batch))? + .with_query_string_parameters(query_parameters); + + let response = influxdb_timestream_connector::lambda_handler(&client, request) + .await? + .into_response() + .await; + println!("Response: {}: {:?}", response.status(), response.body()); + assert!(response.status() == StatusCode::OK); + + let mut cleanup_batch = + CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup().await; + + Ok(()) +} + +#[tokio::test] +#[should_panic] +async fn test_mtmm_no_credentials() { + // Tests ingesting without AWS credentials. This test should panic. + set_environment_variables(); + + let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .credentials_provider(Credentials::new("", "", None, None, "test")) + .region(Region::new(REGION)) + .load() + .await; + + let (client, reload) = timestream_write::Client::new(&config) + .with_endpoint_discovery_enabled() + .await + .expect("Failed to get the write client connection with Timestream"); + tokio::task::spawn(reload.reload_task()); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point)) + .expect("Failed to created request") + .with_query_string_parameters(query_parameters); + + let _response = influxdb_timestream_connector::lambda_handler(&client, request).await; +} + +#[tokio::test] +#[should_panic] +async fn test_mtmm_incorrect_credentials() { + // Tests ingesting with incorrect AWS credentials. This test should panic. + set_environment_variables(); + + let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) + .credentials_provider(Credentials::new( + "ANOTREAL", + "notrealrnrELgWzOk3IfjzDKtFBhDby", + None, + None, + "test", + )) + .region(Region::new(REGION)) + .load() + .await; + + let (client, reload) = timestream_write::Client::new(&config) + .with_endpoint_discovery_enabled() + .await + .expect("Failed to get the write client connection with Timestream"); + tokio::task::spawn(reload.reload_task()); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = Request::builder() + .body(Body::Text(point)) + .expect("Failed to created request") + .with_query_string_parameters(query_parameters); + + let _response = influxdb_timestream_connector::lambda_handler(&client, request).await; +} diff --git a/integrations/influxdb_connector/sample-influxdb-clients/README.md b/integrations/influxdb_connector/sample-influxdb-clients/README.md new file mode 100644 index 00000000..00cdd726 --- /dev/null +++ b/integrations/influxdb_connector/sample-influxdb-clients/README.md @@ -0,0 +1,7 @@ +# Amazon sample InfluxDB client applications + +## Getting started + +Below is a list of sample applications to use with the InfluxDB Timestream Connector. The sample applications are implementations of the [InfluxDB client libraries](https://docs.influxdata.com/influxdb/v1/tools/api_client_libraries/) that ingest a sample line protocol dataset and use SigV4 authentication for each request. Each sample is fully functional and can ingest data to Timestream for LiveAnalytics with the InfluxDB Timestream Connector running locally, or deployed as a Lambda function with a REST API Gateway. + +* [Go](https://github.com/awslabs/amazon-timestream-tools/blob/mainline/integrations/influxdb_connector/sample-influxdb-clients/go) diff --git a/integrations/influxdb_connector/sample-influxdb-clients/data/bird-migration.line b/integrations/influxdb_connector/sample-influxdb-clients/data/bird-migration.line new file mode 100644 index 00000000..480e77ee --- /dev/null +++ b/integrations/influxdb_connector/sample-influxdb-clients/data/bird-migration.line @@ -0,0 +1,2002 @@ +migration1,id=91752A,s2_cell_id=164b35c lat=8.3495,lon=39.01233 1554123600000000000 +migration1,id=91752A,s2_cell_id=164b3dc lat=8.56067,lon=39.08883 1554102000000000000 +migration1,id=91752A,s2_cell_id=17b4854 lat=7.86233,lon=38.81167 1547557200000000000 +migration1,id=91752A,s2_cell_id=17b4854 lat=7.883,lon=38.7975 1553065200000000000 +migration1,id=91752A,s2_cell_id=17b4854 lat=7.86233,lon=38.81217 1553670000000000000 +migration1,id=91752A,s2_cell_id=17b4854 lat=7.862,lon=38.81233 1553929200000000000 +migration1,id=91752A,s2_cell_id=17b4854 lat=7.86217,lon=38.81217 1554382800000000000 +migration1,id=91752A,s2_cell_id=17b4854 lat=7.86183,lon=38.81233 1554706800000000000 +migration1,id=91752A,s2_cell_id=17b4854 lat=7.86233,lon=38.81217 1554728400000000000 +migration1,id=91752A,s2_cell_id=17b4854 lat=7.8675,lon=38.819 1554782400000000000 +migration1,id=91752A,s2_cell_id=17b48fc lat=7.87067,lon=38.82233 1554750000000000000 +migration1,id=91752A,s2_cell_id=17b4944 lat=7.97617,lon=38.91567 1553778000000000000 +migration1,id=91752A,s2_cell_id=17b4944 lat=7.97617,lon=38.91567 1553842800000000000 +migration1,id=91752A,s2_cell_id=17b4944 lat=7.97967,lon=38.92467 1554274800000000000 +migration1,id=91752A,s2_cell_id=17b495c lat=7.99767,lon=38.93017 1558530000000000000 +migration1,id=91752A,s2_cell_id=17b495c lat=8.0015,lon=38.91317 1559890800000000000 +migration1,id=91752A,s2_cell_id=17b4964 lat=8.01367,lon=38.88867 1550818800000000000 +migration1,id=91752A,s2_cell_id=17b4964 lat=8.00583,lon=38.88833 1553864400000000000 +migration1,id=91752A,s2_cell_id=17b4964 lat=7.9915,lon=38.86633 1554955200000000000 +migration1,id=91752A,s2_cell_id=17b4964 lat=8.0065,lon=38.87583 1559113200000000000 +migration1,id=91752A,s2_cell_id=17b4974 lat=7.9695,lon=38.8615 1553918400000000000 +migration1,id=91752A,s2_cell_id=17b497c lat=8.002,lon=38.83383 1553540400000000000 +migration1,id=91752A,s2_cell_id=17b497c lat=8.00467,lon=38.82667 1553659200000000000 +migration1,id=91752A,s2_cell_id=17b497c lat=7.99117,lon=38.85767 1559912400000000000 +migration1,id=91752A,s2_cell_id=17b4984 lat=8.00467,lon=38.779 1553713200000000000 +migration1,id=91752A,s2_cell_id=17b4984 lat=8.00583,lon=38.78117 1560744000000000000 +migration2,id=91752A,s2_cell_id=17b498c lat=8.00883,lon=38.75417 1553691600000000000 +migration2,id=91752A,s2_cell_id=17b498c lat=8.0115,lon=38.7555 1555138800000000000 +migration2,id=91752A,s2_cell_id=17b498c lat=8.03267,lon=38.75467 1555646400000000000 +migration2,id=91752A,s2_cell_id=17b498c lat=8.02833,lon=38.755 1555678800000000000 +migration2,id=91752A,s2_cell_id=17b498c lat=8.0285,lon=38.75767 1558152000000000000 +migration2,id=91752A,s2_cell_id=17b498c lat=7.99967,lon=38.75317 1560344400000000000 +migration2,id=91752A,s2_cell_id=17b498c lat=8.01383,lon=38.76267 1561262400000000000 +migration2,id=91752A,s2_cell_id=17b498c lat=8.03117,lon=38.76433 1568260800000000000 +migration2,id=91752A,s2_cell_id=17b498c lat=8.00483,lon=38.74467 1570345200000000000 +migration2,id=91752A,s2_cell_id=17b498c lat=8.03233,lon=38.76233 1572667200000000000 +migration2,id=91752A,s2_cell_id=17b498c lat=8.01033,lon=38.75083 1573995600000000000 +migration2,id=91752A,s2_cell_id=17b498c lat=8.01617,lon=38.77833 1575291600000000000 +migration2,id=91752A,s2_cell_id=17b4994 lat=7.98467,lon=38.7635 1556866800000000000 +migration2,id=91752A,s2_cell_id=17b4994 lat=7.97217,lon=38.77233 1556953200000000000 +migration2,id=91752A,s2_cell_id=17b4994 lat=7.985,lon=38.75 1557126000000000000 +migration2,id=91752A,s2_cell_id=17b4994 lat=7.98267,lon=38.76433 1557385200000000000 +migration2,id=91752A,s2_cell_id=17b4994 lat=7.9825,lon=38.757 1560754800000000000 +migration2,id=91752A,s2_cell_id=17b4994 lat=7.99067,lon=38.75317 1561964400000000000 +migration2,id=91752A,s2_cell_id=17b4994 lat=7.9905,lon=38.75133 1562677200000000000 +migration2,id=91752A,s2_cell_id=17b4994 lat=7.96817,lon=38.7715 1576587600000000000 +migration2,id=91752A,s2_cell_id=17b49a4 lat=7.94583,lon=38.79917 1553626800000000000 +migration2,id=91752A,s2_cell_id=17b49ac lat=7.8905,lon=38.81433 1554836400000000000 +migration2,id=91752A,s2_cell_id=17b49ac lat=7.90283,lon=38.81817 1555009200000000000 +migration2,id=91752A,s2_cell_id=17b49b4 lat=7.90683,lon=38.753 1554966000000000000 +migration2,id=91752A,s2_cell_id=17b49b4 lat=7.91617,lon=38.772 1555041600000000000 +migration2,id=91752A,s2_cell_id=17b49bc lat=7.94683,lon=38.7705 1560322800000000000 +migration2,id=91752A,s2_cell_id=17b49bc lat=7.95533,lon=38.745 1577106000000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94433,lon=38.72983 1554210000000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94217,lon=38.73033 1554404400000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.9525,lon=38.73433 1554793200000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.9475,lon=38.72767 1555398000000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.9475,lon=38.72767 1556197200000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94633,lon=38.727 1556974800000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94183,lon=38.73033 1559998800000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.9445,lon=38.73 1560495600000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.9445,lon=38.73 1560517200000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94433,lon=38.72983 1560582000000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.9445,lon=38.73 1561035600000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94233,lon=38.73067 1561057200000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.9445,lon=38.73 1561208400000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94233,lon=38.73067 1561230000000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94417,lon=38.72967 1561294800000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.9445,lon=38.73 1561381200000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94283,lon=38.731 1561402800000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94217,lon=38.7305 1561489200000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94283,lon=38.731 1561748400000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94283,lon=38.731 1561780800000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94217,lon=38.7305 1561921200000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.944,lon=38.72933 1561953600000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.942,lon=38.73033 1562007600000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.9445,lon=38.73 1562569200000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94283,lon=38.731 1562742000000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.9475,lon=38.72767 1562763600000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94183,lon=38.73033 1562785200000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.94433,lon=38.72983 1562817600000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.942,lon=38.7305 1562871600000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.9445,lon=38.73 1562904000000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.9475,lon=38.72767 1568271600000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.95,lon=38.73167 1574082000000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.93067,lon=38.73567 1574859600000000000 +migration2,id=91752A,s2_cell_id=17b49c4 lat=7.93633,lon=38.72867 1575378000000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.04433,lon=38.766 1546326000000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.06183,lon=38.77617 1555830000000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.05933,lon=38.775 1556856000000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.04633,lon=38.77667 1560571200000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.05417,lon=38.7735 1562828400000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.05317,lon=38.77383 1562914800000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.05567,lon=38.77317 1562936400000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.0545,lon=38.77333 1564113600000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.05817,lon=38.77767 1564718400000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.04917,lon=38.77867 1565323200000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.03933,lon=38.76617 1565841600000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.06783,lon=38.774 1566360000000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.04667,lon=38.76783 1566457200000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.0515,lon=38.77333 1567569600000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.044,lon=38.77017 1571490000000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.05183,lon=38.77833 1571684400000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.0365,lon=38.76083 1572332400000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.04183,lon=38.7615 1572872400000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.04683,lon=38.77583 1574319600000000000 +migration2,id=91752A,s2_cell_id=17b4a24 lat=8.04133,lon=38.76533 1575118800000000000 +migration2,id=91752A,s2_cell_id=17b4a2c lat=8.05767,lon=38.78033 1555052400000000000 +migration2,id=91752A,s2_cell_id=17b4a2c lat=8.04267,lon=38.80733 1556607600000000000 +migration2,id=91752A,s2_cell_id=17b4a2c lat=8.05183,lon=38.78083 1560312000000000000 +migration2,id=91752A,s2_cell_id=17b4a2c lat=8.06633,lon=38.78367 1560484800000000000 +migration2,id=91752A,s2_cell_id=17b4a2c lat=8.03233,lon=38.7885 1560841200000000000 +migration2,id=91752A,s2_cell_id=17b4a2c lat=8.0635,lon=38.7885 1562385600000000000 +migration2,id=91752A,s2_cell_id=17b4a2c lat=8.06417,lon=38.80233 1563076800000000000 +migration2,id=91752A,s2_cell_id=17b4a2c lat=8.0615,lon=38.78733 1567742400000000000 +migration2,id=91752A,s2_cell_id=17b4a2c lat=8.062,lon=38.7795 1568952000000000000 +migration2,id=91752A,s2_cell_id=17b4a2c lat=8.0425,lon=38.78117 1572505200000000000 +migration2,id=91752A,s2_cell_id=17b4a2c lat=8.0635,lon=38.806 1576825200000000000 +migration2,id=91752A,s2_cell_id=17b4a2c lat=8.03883,lon=38.817 1577170800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09433,lon=38.80567 1548442800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07617,lon=38.8145 1548907200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07383,lon=38.7805 1550516400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09167,lon=38.81417 1551877200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.072,lon=38.77883 1552222800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.073,lon=38.7805 1552330800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09383,lon=38.79083 1552395600000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.089,lon=38.81917 1552482000000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.091,lon=38.7925 1554177600000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09483,lon=38.80667 1556942400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.08367,lon=38.79933 1558076400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.06867,lon=38.77967 1558119600000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09833,lon=38.79667 1558324800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07,lon=38.7895 1559275200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07633,lon=38.79383 1559372400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.0695,lon=38.78633 1560085200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07633,lon=38.8035 1561348800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07333,lon=38.79217 1562050800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07917,lon=38.79367 1562212800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07933,lon=38.79367 1562731200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.074,lon=38.80033 1563163200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09617,lon=38.80783 1563422400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.086,lon=38.79283 1563508800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07167,lon=38.79583 1563595200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.072,lon=38.81983 1563606000000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.0715,lon=38.7975 1563768000000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.0845,lon=38.79267 1563854400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.072,lon=38.781 1563951600000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09733,lon=38.805 1564027200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.0755,lon=38.7925 1564200000000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09517,lon=38.79433 1564470000000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09283,lon=38.81083 1564815600000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07483,lon=38.808 1564891200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09883,lon=38.80583 1564977600000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09833,lon=38.81 1565150400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.06867,lon=38.80583 1565236800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.0945,lon=38.813 1565409600000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.0915,lon=38.80283 1565582400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09967,lon=38.80067 1565755200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09767,lon=38.805 1566014400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07067,lon=38.78417 1566100800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09333,lon=38.81483 1566187200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09083,lon=38.82033 1566975600000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.08233,lon=38.8085 1567656000000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.098,lon=38.79333 1568206800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.10017,lon=38.79867 1568520000000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.08167,lon=38.786 1569049200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.0745,lon=38.77917 1570334400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09183,lon=38.81267 1571641200000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.08867,lon=38.80867 1571716800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.08867,lon=38.80867 1571770800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.0915,lon=38.80567 1571900400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.08467,lon=38.78783 1571976000000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.093,lon=38.81333 1572408000000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.0895,lon=38.79667 1572440400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07817,lon=38.80333 1572580800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09617,lon=38.80117 1572753600000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07817,lon=38.8 1572840000000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.09483,lon=38.80933 1573012800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.08183,lon=38.80767 1573239600000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.08867,lon=38.80883 1573498800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.0815,lon=38.81583 1573736400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.08867,lon=38.80867 1573758000000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.08133,lon=38.791 1573822800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.08867,lon=38.80867 1573844400000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.08867,lon=38.80967 1573930800000000000 +migration2,id=91752A,s2_cell_id=17b4a34 lat=8.07717,lon=38.81817 1574168400000000000 +migration2,id=91752A,s2_cell_id=17b4a3c lat=8.07067,lon=38.77717 1550343600000000000 +migration2,id=91752A,s2_cell_id=17b4a3c lat=8.07483,lon=38.77633 1552546800000000000 +migration2,id=91752A,s2_cell_id=17b4a3c lat=8.07483,lon=38.77633 1552935600000000000 +migration2,id=91752A,s2_cell_id=17b4a3c lat=8.07383,lon=38.77783 1553086800000000000 +migration2,id=91752A,s2_cell_id=17b4a3c lat=8.07167,lon=38.77017 1555819200000000000 +migration2,id=91752A,s2_cell_id=17b4a3c lat=8.07117,lon=38.775 1556078400000000000 +migration2,id=91752A,s2_cell_id=17b4a3c lat=8.072,lon=38.77183 1560150000000000000 +migration2,id=91752A,s2_cell_id=17b4a3c lat=8.06783,lon=38.77567 1562126400000000000 +migration2,id=91752A,s2_cell_id=17b4a3c lat=8.076,lon=38.777 1564632000000000000 +migration2,id=91752A,s2_cell_id=17b4a3c lat=8.07,lon=38.777 1568088000000000000 +migration2,id=91752A,s2_cell_id=17b4a4c lat=8.11233,lon=38.80733 1555992000000000000 +migration2,id=91752A,s2_cell_id=17b4a4c lat=8.102,lon=38.80283 1556002800000000000 +migration2,id=91752A,s2_cell_id=17b4a4c lat=8.11183,lon=38.807 1556089200000000000 +migration2,id=91752A,s2_cell_id=17b4a4c lat=8.11183,lon=38.807 1556596800000000000 +migration2,id=91752A,s2_cell_id=17b4a4c lat=8.103,lon=38.80467 1564059600000000000 +migration2,id=91752A,s2_cell_id=17b4a4c lat=8.103,lon=38.7975 1566964800000000000 +migration2,id=91752A,s2_cell_id=17b4a4c lat=8.1005,lon=38.8005 1571403600000000000 +migration2,id=91752A,s2_cell_id=17b4a4c lat=8.10183,lon=38.7955 1572494400000000000 +migration3,id=91752A,s2_cell_id=17b4a4c lat=8.10283,lon=38.79933 1573218000000000000 +migration3,id=91752A,s2_cell_id=17b4b74 lat=8.1615,lon=38.905 1554091200000000000 +migration3,id=91752A,s2_cell_id=17b4b94 lat=8.08583,lon=38.939 1553227200000000000 +migration3,id=91752A,s2_cell_id=17b4b94 lat=8.087,lon=38.938 1553238000000000000 +migration3,id=91752A,s2_cell_id=17b4b94 lat=8.08767,lon=38.943 1553583600000000000 +migration3,id=91752A,s2_cell_id=17b4b94 lat=8.08983,lon=38.93283 1554015600000000000 +migration3,id=91752A,s2_cell_id=17b4b94 lat=8.10483,lon=38.92283 1555214400000000000 +migration3,id=91752A,s2_cell_id=17b4b94 lat=8.0895,lon=38.90833 1556175600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86583 1546315200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.068,lon=38.88583 1546498800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.86517 1546542000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.059,lon=38.86567 1546574400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05933,lon=38.86583 1546585200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86583 1546660800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86583 1546671600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.059,lon=38.86583 1546747200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.057,lon=38.87433 1546758000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86583 1546833600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.06,lon=38.8665 1546844400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05933,lon=38.866 1546920000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86567 1546930800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86567 1546952400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05933,lon=38.86583 1547017200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86583 1547038800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.866 1547060400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05933,lon=38.86617 1547092800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.06,lon=38.86683 1547103600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86567 1547125200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.059,lon=38.86583 1547179200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05983,lon=38.86617 1547190000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86583 1547211600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.059,lon=38.86583 1547265600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05983,lon=38.86633 1547298000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.86567 1547352000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86583 1547362800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05967,lon=38.8665 1547384400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86583 1547449200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86583 1547470800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86583 1547535600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0595,lon=38.86617 1547611200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.059,lon=38.86533 1547643600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.059,lon=38.86517 1547697600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05967,lon=38.8665 1547708400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0595,lon=38.8665 1547730000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0595,lon=38.86633 1547794800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.86617 1547816400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.86567 1547870400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.86633 1547881200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05633,lon=38.8795 1547956800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.06017,lon=38.8665 1547967600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86533 1547989200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.865 1548043200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05967,lon=38.86667 1548054000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.86483 1548075600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.865 1548162000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.865 1548313200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.865 1548334800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86533 1548388800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.865 1548421200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.865 1548475200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.86483 1548507600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05933,lon=38.86633 1548561600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0595,lon=38.8665 1548572400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05967,lon=38.8665 1548594000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05967,lon=38.86667 1548648000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.86483 1548658800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.865 1548680400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05967,lon=38.8665 1548734400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05967,lon=38.86667 1548745200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.86583 1548766800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.059,lon=38.866 1548853200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05967,lon=38.8665 1548918000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.865 1548939600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05933,lon=38.8665 1548961200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0595,lon=38.86667 1548993600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0675,lon=38.88617 1549026000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.86583 1549090800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.86583 1549198800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0595,lon=38.86633 1549263600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05933,lon=38.86617 1549285200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.865 1549350000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05967,lon=38.8665 1549371600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05967,lon=38.8665 1549436400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.866 1549458000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.866 1549512000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05933,lon=38.86633 1549544400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.86583 1549598400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05933,lon=38.8665 1549609200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86533 1549630800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0595,lon=38.8665 1549695600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.059,lon=38.866 1549717200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.059,lon=38.8665 1549771200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0595,lon=38.86683 1549782000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.866 1549803600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.059,lon=38.86633 1549857600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.059,lon=38.86617 1549868400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.86517 1549890000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86533 1549944000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.059,lon=38.866 1549954800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.866 1549976400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.865 1550030400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86533 1550062800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1550149200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86667 1550214000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.86567 1550235600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.86467 1550300400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1550322000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.86667 1550376000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86533 1550408400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0655,lon=38.88767 1550462400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86533 1550473200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0575,lon=38.86783 1550548800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86483 1550559600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.86567 1550581200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1550646000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1550667600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05783,lon=38.86667 1550721600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.865 1550732400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.86583 1550754000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.865 1550840400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86467 1550894400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86467 1550905200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.865 1550926800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.865 1550991600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86483 1551067200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86483 1551078000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86467 1551153600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86467 1551164400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86467 1551186000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.865 1551240000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1551250800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86533 1551337200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1551358800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86517 1551326400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05933,lon=38.86667 1551412800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86533 1551423600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.865 1551445200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86533 1551510000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.865 1551531600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05933,lon=38.86683 1551585600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86533 1551618000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05933,lon=38.86667 1551672000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.865 1551704400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0595,lon=38.867 1551758400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.06,lon=38.867 1551769200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.865 1551790800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05967,lon=38.86683 1551855600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86533 1551942000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86533 1552028400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.865 1552104000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86517 1552136400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05817,lon=38.8655 1552190400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.865 1552276800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86483 1552287600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86483 1552654800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.06117,lon=38.8835 1552827600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1552914000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.86467 1553000400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.8655 1553173200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86533 1553324400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05817,lon=38.8655 1553346000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1553432400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.8655 1553518800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1553605200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86467 1553950800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.06367,lon=38.86367 1554436800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.06517,lon=38.864 1554534000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.06633,lon=38.863 1554577200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0565,lon=38.89817 1554922800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86467 1555225200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.86483 1555246800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.07,lon=38.89 1555311600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05817,lon=38.86533 1555333200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05867,lon=38.86517 1555506000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1555743600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1556110800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1556262000000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1556348400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.0585,lon=38.86517 1556434800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05833,lon=38.86417 1557061200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.86483 1557633600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05917,lon=38.8875 1559804400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.07133,lon=38.8925 1560862800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.06533,lon=38.89833 1561694400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.8625 1563022800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.86267 1563109200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05883,lon=38.868 1563249600000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.06567,lon=38.89317 1563260400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.058,lon=38.863 1568444400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.075,lon=38.89217 1569589200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.05983,lon=38.8795 1569826800000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.06367,lon=38.88233 1571198400000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.06133,lon=38.901 1575961200000000000 +migration3,id=91752A,s2_cell_id=17b4bc4 lat=8.061,lon=38.86817 1577797200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0645,lon=38.859 1546347600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.85633 1546369200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.85883 1546401600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06467,lon=38.85867 1546412400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06433,lon=38.85883 1546434000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85883 1546455600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.85883 1546488000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.859 1546520400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.85867 1546606800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.85883 1546628400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.859 1546693200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.85883 1546714800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.85983 1546779600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.85883 1546801200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.859 1546866000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.85883 1546887600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.85883 1546974000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.08583,lon=38.8215 1547006400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.859 1547146800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85883 1547233200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06583,lon=38.85 1547276400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85917 1547319600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0595,lon=38.85383 1547578800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.85583 1547838000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.859 1547902800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0615,lon=38.857 1547924400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85633 1548010800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85583 1548183600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0685,lon=38.82867 1548248400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.85617 1548529200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.85867 1548615600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85867 1548702000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.85567 1548788400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06033,lon=38.84717 1548820800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.85867 1548874800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06433,lon=38.85867 1549047600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.83967 1549080000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.85867 1549134000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06433,lon=38.85867 1549220400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85867 1549252800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85867 1549306800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85867 1549393200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.859 1549425600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06367,lon=38.8585 1549479600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.85867 1549566000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.85867 1549652400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.8585 1549738800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06383,lon=38.8585 1549825200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.8585 1549911600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06033,lon=38.85517 1550084400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85483 1550170800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.84917 1550203200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06183,lon=38.85517 1550257200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.858 1550289600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.85467 1550602800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.8545 1550775600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85417 1550862000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85833 1550948400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85833 1551034800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.8525 1551099600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.8585 1551294000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.8585 1551326400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06433,lon=38.8585 1551380400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.8585 1551466800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85817 1551553200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.065,lon=38.8585 1551639600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.8585 1551726000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06383,lon=38.8585 1551812400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.8585 1551844800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.8585 1551898800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.08033,lon=38.82433 1551963600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06533,lon=38.85833 1551985200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85867 1552017600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06717,lon=38.83383 1552050000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06483,lon=38.8585 1552071600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0645,lon=38.8585 1552158000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0645,lon=38.85833 1552244400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.8585 1552417200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0645,lon=38.85833 1552503600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.07083,lon=38.83267 1552568400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85833 1552762800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06517,lon=38.85817 1552795200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.855 1553022000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06033,lon=38.85633 1553108400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.85867 1553400000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06517,lon=38.858 1553486400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85867 1553497200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85817 1554188400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.8585 1554264000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.85867 1554350400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.85867 1554361200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06517,lon=38.85867 1554447600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06433,lon=38.85883 1554469200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06483,lon=38.85867 1554490800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06667,lon=38.86017 1554555600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06467,lon=38.8585 1554642000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06467,lon=38.8585 1554814800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06767,lon=38.82583 1554868800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0645,lon=38.85867 1554901200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85417 1555074000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85867 1555160400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85883 1555387200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85917 1555419600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0595,lon=38.85983 1555484400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.8585 1555560000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85883 1555570800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85833 1555592400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85867 1555657200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85883 1555765200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8585 1555851600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85883 1556024400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.8585 1556046000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85933 1556251200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.85867 1556370000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8585 1556456400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.07,lon=38.834 1556510400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0795,lon=38.82783 1556521200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.08467,lon=38.8255 1556629200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85867 1556715600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85867 1556802000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85867 1556888400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85867 1556996400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85867 1557028800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85867 1557082800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.071,lon=38.83483 1557115200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85817 1557147600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.07917,lon=38.82733 1557201600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.8585 1557234000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.85883 1557298800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05867,lon=38.8615 1557320400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.85883 1557374400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0585,lon=38.86217 1557406800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.86217 1557471600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85867 1557579600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.8615 1557644400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05867,lon=38.86233 1557666000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.858 1557687600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8585 1557720000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8585 1557730800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8585 1557752400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85783 1557806400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.858 1557817200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8585 1557892800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8585 1557903600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.8585 1557925200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8585 1557990000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.8585 1558011600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.8585 1558065600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85867 1558098000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85867 1558162800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06767,lon=38.82867 1558184400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85783 1558238400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85583 1558249200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.8585 1558270800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8585 1558335600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85783 1558357200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.855 1558411200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.85783 1558422000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85867 1558443600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.858 1558497600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8585 1558508400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85867 1558584000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8585 1558594800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85833 1558616400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.827 1558638000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85867 1558670400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.8585 1558681200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85833 1558702800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.08117,lon=38.82917 1558756800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85867 1558767600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.8585 1558789200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85867 1558843200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85867 1558854000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05833,lon=38.86183 1558875600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85867 1558929600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85867 1558940400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85783 1558962000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85783 1558983600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.082,lon=38.82883 1559016000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.07983,lon=38.823 1559026800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85817 1559048400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06367,lon=38.85783 1559102400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85833 1559134800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.858 1559156400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0755,lon=38.8295 1559199600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85783 1559221200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85867 1559286000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.858 1559307600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8585 1559361600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.858 1559394000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.85867 1559415600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.8585 1559448000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85867 1559458800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85867 1559480400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06483,lon=38.85817 1559502000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85867 1559545200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06367,lon=38.85833 1559588400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06567,lon=38.84333 1559620800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85867 1559631600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85867 1559653200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.85817 1559674800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85867 1559718000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85867 1559739600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.858 1559761200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.8585 1559793600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85867 1559826000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85867 1559880000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85867 1559977200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85867 1560139200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85867 1560225600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.858 1560236400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.85817 1560258000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06683,lon=38.86083 1560398400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85833 1560603600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85867 1560657600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05833,lon=38.86133 1560690000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.8585 1560776400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85867 1560830400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85867 1560916800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.858 1560949200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.8585 1561014000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.8585 1561089600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.067,lon=38.86017 1561100400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0585,lon=38.86183 1561359600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.09167,lon=38.82417 1561446000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05867,lon=38.86183 1561467600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05833,lon=38.86183 1561521600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.86217 1561618800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05867,lon=38.862 1561640400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05867,lon=38.862 1561791600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05833,lon=38.86217 1561813200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05833,lon=38.8615 1561834800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.83967 1561867200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05817,lon=38.862 1561878000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05867,lon=38.86167 1561986000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06367,lon=38.858 1562094000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05867,lon=38.862 1562137200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0585,lon=38.86217 1562158800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0645,lon=38.85867 1562180400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0585,lon=38.862 1562223600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0585,lon=38.86217 1562245200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0585,lon=38.862 1562310000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0585,lon=38.862 1562331600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.86217 1562396400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.862 1562418000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06367,lon=38.858 1562439600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.86183 1562482800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0585,lon=38.86233 1562504400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05833,lon=38.862 1562590800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05817,lon=38.86167 1562644800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.8565 1562850000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.85567 1563087600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85583 1563130800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.85567 1563174000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.85567 1563195600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8555 1563217200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.8555 1563282000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85567 1563303600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.8555 1563346800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.84433 1563368400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.8555 1563433200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.8555 1563454800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85583 1563476400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.8565 1563519600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85533 1563541200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.85617 1563562800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.8555 1563627600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85583 1563649200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.8565 1563681600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.83683 1563692400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06367,lon=38.85567 1563714000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.8555 1563735600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.8555 1563778800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06167,lon=38.85467 1563800400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85533 1563822000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0615,lon=38.85483 1563865200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.08483,lon=38.82483 1563886800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.85567 1563908400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06167,lon=38.85533 1563940800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06167,lon=38.85617 1563973200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.85533 1563994800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.84917 1564038000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85717 1564081200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.856 1564124400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.85617 1564146000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.8555 1564167600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.85583 1564210800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.856 1564232400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.856 1564254000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.85567 1564286400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.856 1564297200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06817,lon=38.83033 1564318800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.85583 1564340400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06783,lon=38.8255 1564372800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.85533 1564383600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.856 1564405200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85617 1564426800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.08817,lon=38.82333 1564459200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.855 1564491600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85583 1564513200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.067,lon=38.8235 1564545600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.856 1564556400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0615,lon=38.85483 1564578000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85567 1564599600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85583 1564642800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.8555 1564664400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.85833 1564686000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.8555 1564729200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.8575 1564750800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.85833 1564772400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06167,lon=38.856 1564804800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.85567 1564837200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85817 1564858800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85733 1564902000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.857 1564923600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85583 1564945200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85583 1564988400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.85633 1565010000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.85667 1565031600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85617 1565064000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.856 1565074800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06033,lon=38.85717 1565096400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85633 1565118000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85683 1565161200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85667 1565182800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85633 1565204400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85683 1565247600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.857 1565269200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.85683 1565334000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.857 1565355600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.8565 1565377200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85683 1565420400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1565442000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.85617 1565463600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.08617,lon=38.825 1565496000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.85683 1565506800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.857 1565528400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.85633 1565550000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.85633 1565593200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85683 1565614800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85633 1565636400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06667,lon=38.84383 1565668800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85633 1565679600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0595,lon=38.85633 1565701200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.85633 1565722800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.85717 1565766000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.857 1565787600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.85633 1565809200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.85633 1565852400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85633 1565874000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.85717 1565895600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0855,lon=38.82267 1565928000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0595,lon=38.8565 1565938800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.8575 1565960400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.85667 1565982000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.857 1566025200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85883 1566046800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.85717 1566068400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06033,lon=38.85733 1566111600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85883 1566133200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.857 1566198000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0595,lon=38.85617 1566219600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.8565 1566241200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06033,lon=38.85633 1566273600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.857 1566284400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.85717 1566306000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.85717 1566327600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0595,lon=38.85767 1566370800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.8565 1566392400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85717 1566414000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.857 1566446400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.85667 1566478800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.8575 1566500400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85733 1566532800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06033,lon=38.85717 1566543600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.083,lon=38.82183 1566565200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.8575 1566586800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.8515 1566619200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.8585 1566630000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85867 1566673200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06167,lon=38.84517 1566705600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85633 1566716400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.85633 1566738000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85717 1566759600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0755,lon=38.82983 1566792000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1566802800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85633 1566824400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.857 1566846000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0645,lon=38.83117 1566878400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.8565 1566889200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.85617 1566910800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.85633 1566932400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85667 1566997200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85667 1567018800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0905,lon=38.822 1567051200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.8565 1567062000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85667 1567083600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.857 1567105200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.08333,lon=38.82633 1567137600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85683 1567148400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85617 1567170000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85667 1567191600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.85667 1567224000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06033,lon=38.85717 1567234800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.8565 1567256400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06033,lon=38.85733 1567278000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06783,lon=38.83617 1567310400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.8565 1567321200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85683 1567342800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.8575 1567364400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.85733 1567396800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.07467,lon=38.82917 1567407600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.858 1567450800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.08967,lon=38.82317 1567483200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.858 1567494000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06183,lon=38.858 1567515600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06033,lon=38.85717 1567537200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.8575 1567580400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.85717 1567602000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.8575 1567623600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85817 1567666800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85817 1567688400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.858 1567710000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.858 1567753200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.85817 1567774800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.858 1567796400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.848 1567828800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.858 1567839600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.858 1567861200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.858 1567882800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85717 1567915200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85817 1567926000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85767 1567947600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.85733 1567969200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.85833 1568001600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85817 1568012400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.8585 1568034000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.85717 1568055600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85867 1568098800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06517,lon=38.857 1568120400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85733 1568142000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.85817 1568174400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.858 1568185200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.858 1568228400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06183,lon=38.85767 1568293200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06183,lon=38.85767 1568347200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.8575 1568358000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.85733 1568379600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1568401200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.858 1568433600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.85067 1568466000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.85767 1568487600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.85767 1568552400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.857 1568574000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.8575 1568606400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06183,lon=38.85767 1568617200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06167,lon=38.85767 1568638800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.85767 1568660400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06167,lon=38.85767 1568692800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.858 1568703600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06183,lon=38.85767 1568725200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85817 1568779200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85817 1568790000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06183,lon=38.85783 1568811600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85783 1568833200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06483,lon=38.85417 1568876400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.85783 1568898000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.85767 1568919600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06433,lon=38.8575 1568962800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06433,lon=38.8575 1568984400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.85817 1569006000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.071,lon=38.82233 1569070800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.85833 1569092400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.85783 1569135600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0645,lon=38.8575 1569157200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85833 1569178800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.858 1569211200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.858 1569222000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.8575 1569243600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.858 1569265200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85767 1569297600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.858 1569308400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06433,lon=38.8575 1569330000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.8585 1569351600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85767 1569384000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8575 1569394800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.858 1569416400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85767 1569438000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85783 1569470400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0615,lon=38.85817 1569481200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.85767 1569502800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.8585 1569524400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.8575 1569556800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.85233 1569567600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85767 1569610800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.8535 1569643200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85767 1569654000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06467,lon=38.8475 1569675600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.858 1569697200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.85833 1569729600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85767 1569740400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.858 1569762000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85767 1569783600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85767 1569816000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.858 1569848400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.85883 1569870000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06383,lon=38.85783 1569902400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06483,lon=38.8575 1569913200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85783 1569934800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.85767 1569956400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.8465 1569988800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06383,lon=38.85767 1569999600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06483,lon=38.85767 1570021200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85783 1570042800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85833 1570075200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.85733 1570086000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06383,lon=38.85783 1570107600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.858 1570129200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85783 1570161600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06467,lon=38.85817 1570172400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.85783 1570194000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.8585 1570215600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.858 1570248000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.855 1570258800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06483,lon=38.8575 1570280400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.858 1570302000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06467,lon=38.858 1570366800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06367,lon=38.85783 1570388400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.8575 1570420800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.065,lon=38.8575 1570453200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85817 1570474800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06467,lon=38.85817 1570507200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06433,lon=38.85867 1570518000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06383,lon=38.85783 1570539600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.85817 1570561200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06383,lon=38.8585 1570593600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06483,lon=38.85767 1570604400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.858 1570626000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.8585 1570647600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0625,lon=38.8585 1570680000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85767 1570690800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06367,lon=38.85767 1570712400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0615,lon=38.85833 1570734000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85783 1570766400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85833 1570777200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.85833 1570798800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.85817 1570820400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8575 1570852800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.8585 1570863600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.85817 1570906800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85783 1570939200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.8565 1570950000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.8575 1570971600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.85817 1570993200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.85767 1571025600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06383,lon=38.85767 1571036400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85783 1571058000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85817 1571079600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85783 1571112000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85817 1571166000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0615,lon=38.85833 1571230800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.85817 1571252400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.074,lon=38.82767 1571284800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.858 1571295600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0615,lon=38.85817 1571317200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.85817 1571338800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85783 1571382000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.85817 1571425200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85783 1571468400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0615,lon=38.85833 1571511600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85767 1571544000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85783 1571576400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.85833 1571598000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.8575 1571662800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.8575 1571727600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85817 1571749200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.85817 1571803200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.858 1571835600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.85817 1571857200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06033,lon=38.85733 1571889600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85767 1571922000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85817 1571943600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06183,lon=38.85767 1571986800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06183,lon=38.85783 1572008400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85783 1572030000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85817 1572062400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85783 1572073200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85783 1572094800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.8575 1572116400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85783 1572181200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85783 1572202800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.84983 1572235200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85767 1572246000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85767 1572267600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.85783 1572289200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85783 1572321600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85783 1572354000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.85783 1572375600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85783 1572418800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.858 1572462000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.8575 1572526800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85783 1572548400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85817 1572613200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.85783 1572634800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85767 1572678000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85783 1572699600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.858 1572721200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06033,lon=38.85733 1572786000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85767 1572807600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.858 1572894000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06567,lon=38.83333 1572926400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85783 1572958800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85783 1573045200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85767 1573066800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.062,lon=38.8575 1573099200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.8575 1573131600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85767 1573153200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85817 1573304400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.85783 1573326000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.85783 1573369200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85783 1573390800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85783 1573477200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.858 1573563600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1573617600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.85733 1573650000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1573704000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06167,lon=38.857 1573876800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.85733 1573887600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85783 1573909200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06167,lon=38.85683 1573963200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.85733 1573974000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.8575 1574017200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06717,lon=38.8345 1574049600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06083,lon=38.85783 1574060400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.85733 1574103600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06183,lon=38.85683 1574136000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.8575 1574190000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06233,lon=38.85717 1574254800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.8575 1574276400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.85733 1574308800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.85783 1574341200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06117,lon=38.85733 1574362800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1574395200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.8285 1574427600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.8575 1574449200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06133,lon=38.85733 1574514000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.858 1574535600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85817 1574600400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.858 1574622000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1574654400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1574686800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85817 1574708400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1574740800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85883 1574773200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85817 1574794800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06533,lon=38.83033 1574827200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.858 1574881200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0595,lon=38.85867 1574913600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.85867 1574946000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0605,lon=38.85733 1574967600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1575000000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85717 1575054000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.061,lon=38.85717 1575086400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.8575 1575140400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.85767 1575172800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85783 1575205200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05983,lon=38.85783 1575226800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1575270000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06,lon=38.85767 1575313200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1575345600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.85683 1575442800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06067,lon=38.85817 1575464400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85633 1575486000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06017,lon=38.857 1575518400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.857 1575529200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85683 1575550800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85633 1575572400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.857 1575604800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06217,lon=38.857 1575615600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.85683 1575637200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85633 1575658800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0635,lon=38.85683 1575691200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06333,lon=38.85683 1575702000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85683 1575723600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85633 1575745200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06567,lon=38.82133 1575810000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85633 1575831600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06267,lon=38.85617 1575864000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85667 1575896400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85633 1575918000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06283,lon=38.85633 1575950400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06383,lon=38.85667 1575982800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06367,lon=38.85667 1576047600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06433,lon=38.85667 1576069200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85633 1576090800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06367,lon=38.85667 1576123200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85667 1576155600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85633 1576177200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.063,lon=38.85667 1576209600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.064,lon=38.85667 1576242000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85633 1576263600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06317,lon=38.85633 1576296000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.06417,lon=38.85667 1576328400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85633 1576350000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.8585 1576382400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0615,lon=38.85467 1576414800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85633 1576436400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.85833 1576468800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.8585 1576479600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05967,lon=38.85817 1576501200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.85683 1576555200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.8585 1576566000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.85817 1576609200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85633 1576641600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.85867 1576652400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.8585 1576674000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.8585 1576695600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.85917 1576760400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.8585 1576782000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.85867 1576868400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0595,lon=38.85867 1576933200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.85867 1576954800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.85883 1576987200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.0595,lon=38.8585 1577019600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.85917 1577041200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.859 1577084400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.85933 1577127600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85883 1577192400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.85783 1577214000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.059,lon=38.85917 1577246400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.859 1577278800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.85917 1577300400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.859 1577365200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.859 1577386800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05933,lon=38.85867 1577451600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.85883 1577473200000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.859 1577505600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.85917 1577559600000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.85867 1577624400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.8575 1577646000000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.85933 1577710800000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05883,lon=38.85767 1577732400000000000 +migration3,id=91752A,s2_cell_id=17b4bcc lat=8.05917,lon=38.85733 1577818800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05767,lon=38.85367 1547406000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0515,lon=38.85583 1547438400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05667,lon=38.85283 1547492400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0575,lon=38.86117 1547524800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05767,lon=38.85367 1547665200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05583,lon=38.85317 1547751600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05667,lon=38.85517 1548097200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05367,lon=38.84533 1548140400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.03717,lon=38.839 1548216000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04933,lon=38.84683 1548226800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05883,lon=38.85167 1548270000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05467,lon=38.86133 1548302400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.84767 1548356400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.055,lon=38.8515 1548399600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.048,lon=38.8495 1548486000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05683,lon=38.84583 1548831600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0555,lon=38.86033 1549112400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.03817,lon=38.84483 1549166400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05167,lon=38.85283 1549177200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.02983,lon=38.837 1549339200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0285,lon=38.84783 1549522800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0395,lon=38.847 1549684800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05883,lon=38.854 1549998000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05467,lon=38.85467 1550041200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0565,lon=38.85367 1550386800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05517,lon=38.84383 1550430000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85433 1550689200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05783,lon=38.85867 1550980800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05667,lon=38.8465 1551121200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05633,lon=38.85 1551207600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.06,lon=38.83767 1551272400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05683,lon=38.86167 1551499200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04267,lon=38.84733 1552114800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.034,lon=38.84317 1552201200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05633,lon=38.86117 1552309200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05717,lon=38.8615 1552363200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04017,lon=38.85133 1552374000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0545,lon=38.83867 1552460400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.86167 1552536000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.86183 1552590000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85067 1552676400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0405,lon=38.86233 1552708800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.8575 1552741200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05683,lon=38.86167 1552849200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05717,lon=38.86133 1552881600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.86117 1552968000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.043,lon=38.84617 1552978800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05683,lon=38.86167 1553054400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05717,lon=38.86067 1553140800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.858 1553194800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.85933 1553259600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05717,lon=38.85967 1553313600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.02883,lon=38.841 1553454000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04167,lon=38.84217 1553745600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1553799600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05683,lon=38.86167 1553886000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.86167 1553972400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.03967,lon=38.86033 1554004800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05683,lon=38.86167 1554058800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0575,lon=38.86033 1554145200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.86167 1554231600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.86167 1554318000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04867,lon=38.86217 1554620400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.86167 1554663600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.03783,lon=38.86183 1554696000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05483,lon=38.8555 1554987600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05483,lon=38.85967 1555095600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05817,lon=38.85133 1555128000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1555182000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1555268400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0575,lon=38.8605 1555300800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05833,lon=38.85817 1555354800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1555441200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85833 1555527600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1555614000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1555700400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1555786800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05267,lon=38.84267 1555873200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1555938000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05583,lon=38.84067 1555959600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1556132400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1556218800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1556305200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05283,lon=38.84533 1556337600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1556391600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05117,lon=38.862 1556424000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1556478000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0475,lon=38.85633 1556542800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1556564400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1556650800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.035,lon=38.8525 1556683200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.059,lon=38.84533 1556694000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1556737200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.86083 1556769600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.058,lon=38.85317 1556780400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1556823600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1556910000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05633,lon=38.861 1557039600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05733,lon=38.86033 1557169200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05517,lon=38.85183 1557212400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.86167 1557255600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.86167 1557288000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05733,lon=38.86017 1557342000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.046,lon=38.839 1557428400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1557514800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05583,lon=38.85717 1557558000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1557601200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1557774000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05,lon=38.859 1557838800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1557860400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1557946800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1557979200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1558033200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1558206000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0555,lon=38.86 1558292400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05833,lon=38.85817 1558465200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.86083 1558551600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05633,lon=38.8605 1558724400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05583,lon=38.86067 1558810800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05683,lon=38.861 1558897200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.86083 1559070000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.86033 1559188800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.86067 1559242800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05733,lon=38.86033 1559329200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.85317 1559534400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05217,lon=38.857 1559566800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.06017,lon=38.84267 1559707200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0575,lon=38.8615 1559847600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05633,lon=38.844 1559966400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0485,lon=38.83317 1560020400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05683,lon=38.86083 1560052800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05633,lon=38.86133 1560063600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05667,lon=38.86083 1560106800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.8595 1560171600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.86083 1560193200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0565,lon=38.86083 1560279600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.86083 1560366000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.06,lon=38.84333 1560430800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05667,lon=38.86067 1560452400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0565,lon=38.86083 1560538800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.86083 1560625200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.86067 1560711600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05667,lon=38.86083 1560798000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05583,lon=38.86067 1560884400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05833,lon=38.83833 1560970800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05633,lon=38.86133 1561122000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.86083 1561143600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.861 1561176000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05717,lon=38.86133 1561186800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05683,lon=38.86167 1561273200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1561316400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05717,lon=38.86133 1561532400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05667,lon=38.8605 1561554000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05617,lon=38.86083 1561575600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05717,lon=38.86117 1561608000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.861 1561662000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05783,lon=38.86133 1561705200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05767,lon=38.86133 1561899600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0575,lon=38.86133 1562040000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0575,lon=38.86167 1562072400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0575,lon=38.86133 1562266800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0575,lon=38.86117 1562353200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05483,lon=38.862 1562472000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05683,lon=38.86083 1562526000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05717,lon=38.84133 1562558400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05633,lon=38.8605 1562612400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0575,lon=38.86167 1562655600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05633,lon=38.8605 1562698800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1562958000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.058,lon=38.85783 1562990400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05683,lon=38.86167 1563001200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05833,lon=38.85817 1563044400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.032,lon=38.8485 1563336000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0585,lon=38.85817 1563390000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.048,lon=38.85283 1565290800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05067,lon=38.85583 1568314800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0575,lon=38.8555 1568530800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04583,lon=38.84717 1568746800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04933,lon=38.845 1568865600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04683,lon=38.84617 1570885200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04533,lon=38.844 1571122800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05583,lon=38.845 1571144400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04983,lon=38.84367 1571209200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05633,lon=38.85417 1571371200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04217,lon=38.83867 1571457600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05417,lon=38.849 1571554800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04767,lon=38.84017 1571630400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05033,lon=38.858 1571814000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0595,lon=38.84833 1572159600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04617,lon=38.85933 1572591600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0495,lon=38.84717 1572764400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04817,lon=38.848 1572850800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04867,lon=38.8475 1572937200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05083,lon=38.84967 1572980400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04817,lon=38.847 1573023600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04067,lon=38.84817 1573110000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.049,lon=38.84717 1573196400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04733,lon=38.8385 1573272000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.044,lon=38.846 1573282800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04383,lon=38.8525 1573412400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0395,lon=38.8485 1573455600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0565,lon=38.8215 1573531200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04017,lon=38.84833 1573542000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0485,lon=38.84917 1573585200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.056,lon=38.86167 1573628400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0455,lon=38.85267 1573671600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.036,lon=38.84383 1573714800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.03067,lon=38.83083 1573790400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.03967,lon=38.8425 1573801200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0395,lon=38.84233 1574146800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.03183,lon=38.84083 1574222400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0385,lon=38.84367 1574233200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04417,lon=38.8365 1574481600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.03833,lon=38.844 1574492400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04,lon=38.8485 1574665200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04733,lon=38.84317 1574838000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0345,lon=38.84917 1574924400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04383,lon=38.847 1575010800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05867,lon=38.8565 1575032400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0425,lon=38.8455 1575097200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0375,lon=38.844 1575183600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0465,lon=38.84717 1575356400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05233,lon=38.84483 1575399600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05967,lon=38.83933 1575432000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0425,lon=38.84767 1575874800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04883,lon=38.84917 1576004400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0475,lon=38.845 1576393200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0495,lon=38.84167 1576522800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0515,lon=38.85117 1576728000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.03917,lon=38.8345 1576738800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.03967,lon=38.84183 1576846800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05783,lon=38.84683 1576900800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04617,lon=38.84717 1576911600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04733,lon=38.84683 1576998000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0545,lon=38.846 1577073600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04033,lon=38.85483 1577160000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0385,lon=38.844 1577257200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.04183,lon=38.8365 1577332800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05383,lon=38.84283 1577343600000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0525,lon=38.842 1577419200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.02583,lon=38.84817 1577430000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0355,lon=38.8445 1577516400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05917,lon=38.83417 1577538000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.0575,lon=38.8545 1577592000000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.044,lon=38.8425 1577678400000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.057,lon=38.84333 1577689200000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.05983,lon=38.845 1577764800000000000 +migration3,id=91752A,s2_cell_id=17b4bd4 lat=8.03767,lon=38.83383 1577775600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.05517,lon=38.86417 1547784000000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.05733,lon=38.86567 1548129600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.05533,lon=38.86367 1549004400000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0545,lon=38.8655 1550116800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.02167,lon=38.87217 1550127600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.054,lon=38.88883 1550494800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.047,lon=38.86867 1550635200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.036,lon=38.86833 1550808000000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.02767,lon=38.895 1551013200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.045,lon=38.86983 1551596400000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.03933,lon=38.868 1551682800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.041,lon=38.86417 1551931200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.032,lon=38.88617 1552449600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04667,lon=38.86417 1552622400000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0565,lon=38.868 1552633200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04133,lon=38.87667 1552806000000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04967,lon=38.87833 1553281200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04283,lon=38.88933 1553367600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.05667,lon=38.86383 1553410800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.05117,lon=38.88517 1553756400000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0475,lon=38.8755 1554296400000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0565,lon=38.86867 1554523200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0445,lon=38.8655 1554609600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04583,lon=38.89267 1555473600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0545,lon=38.86783 1555732800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04583,lon=38.86333 1556283600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0555,lon=38.87917 1557547200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.044,lon=38.86717 1558378800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0465,lon=38.86667 1559934000000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.047,lon=38.866 1560409200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.033,lon=38.8645 1560668400000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0565,lon=38.86783 1561003200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.05383,lon=38.88033 1561435200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0535,lon=38.86633 1561726800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0535,lon=38.86283 1562299200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.042,lon=38.86583 1566154800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0525,lon=38.87617 1566651600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.02817,lon=38.88883 1567429200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0565,lon=38.863 1569124800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04383,lon=38.86417 1570431600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04433,lon=38.86733 1572148800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04567,lon=38.87617 1573185600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04617,lon=38.86683 1573358400000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04967,lon=38.87633 1573444800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04733,lon=38.86467 1574568000000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.0455,lon=38.88683 1574751600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.044,lon=38.86867 1575259200000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.05233,lon=38.86717 1575777600000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.02067,lon=38.9005 1576036800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.04467,lon=38.8795 1576134000000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.052,lon=38.86417 1576220400000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.03667,lon=38.89433 1576306800000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.034,lon=38.873 1576814400000000000 +migration3,id=91752A,s2_cell_id=17b4bdc lat=8.03067,lon=38.86533 1577602800000000000 +migration3,id=91752A,s2_cell_id=17b4be4 lat=8.05083,lon=38.913 1547622000000000000 +migration3,id=91752A,s2_cell_id=17b4be4 lat=8.0435,lon=38.91017 1560927600000000000 +migration3,id=91752A,s2_cell_id=17b4be4 lat=8.04333,lon=38.92067 1569038400000000000 +migration3,id=91752A,s2_cell_id=17b4be4 lat=8.0465,lon=38.9115 1574406000000000000 +migration3,id=91752A,s2_cell_id=17b4be4 lat=8.04833,lon=38.91033 1574578800000000000 +migration3,id=91752A,s2_cell_id=17b4be4 lat=8.042,lon=38.91367 1575788400000000000 +migration3,id=91752A,s2_cell_id=17b4bec lat=8.05333,lon=38.92117 1552719600000000000 +migration3,id=91752A,s2_cell_id=17b4bec lat=8.055,lon=38.91683 1552892400000000000 +migration3,id=91752A,s2_cell_id=17b4bec lat=8.0765,lon=38.90433 1553151600000000000 +migration3,id=91752A,s2_cell_id=17b4bec lat=8.07983,lon=38.91667 1553572800000000000 +migration3,id=91752A,s2_cell_id=17b4bec lat=8.05233,lon=38.93433 1553832000000000000 +migration3,id=91752A,s2_cell_id=17b4bec lat=8.0595,lon=38.93767 1554037200000000000 +migration3,id=91752A,s2_cell_id=17b4bec lat=8.08683,lon=38.91367 1554879600000000000 +migration3,id=91752A,s2_cell_id=17b4bec lat=8.08117,lon=38.93567 1555905600000000000 +migration3,id=91752A,s2_cell_id=17b4bec lat=8.06133,lon=38.93333 1555916400000000000 +migration3,id=91752A,s2_cell_id=17b4bec lat=8.0695,lon=38.9185 1556164800000000000 +migration3,id=91752A,s2_cell_id=17b4bec lat=8.06333,lon=38.91117 1557460800000000000 +migration3,id=91752A,s2_cell_id=17b4bec lat=8.06533,lon=38.93633 1557493200000000000 +migration3,id=91761A,s2_cell_id=140317c lat=21.5525,lon=25.26917 1554537600000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51633,lon=24.32467 1554580800000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1554613200000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1554624000000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33217 1554645600000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33217 1554667200000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1554699600000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33217 1554710400000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33217 1554732000000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1554753600000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1554786000000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1554796800000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33217 1554818400000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1554840000000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1554872400000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1554883200000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33217 1554904800000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33233 1554926400000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33217 1554969600000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33217 1554991200000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555012800000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555045200000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555056000000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33217 1555077600000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555099200000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33233 1555131600000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555142400000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33217 1555164000000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33233 1555185600000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555218000000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33233 1555228800000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33233 1555250400000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555272000000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555304400000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555315200000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33217 1555336800000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.51217,lon=24.33233 1555358400000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555390800000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555401600000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555423200000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555477200000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555488000000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555531200000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555563600000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555574400000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555617600000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555650000000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555660800000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555704000000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555736400000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555747200000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555790400000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555822800000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555833600000000000 +migration3,id=91761A,s2_cell_id=140419c lat=22.512,lon=24.33217 1555876800000000000 +migration3,id=91761A,s2_cell_id=140473c lat=22.28717,lon=24.599 1554559200000000000 +migration3,id=91761A,s2_cell_id=141d314 lat=21.0285,lon=25.79083 1554526800000000000 +migration3,id=91761A,s2_cell_id=141d47c lat=20.86767,lon=25.8685 1554494400000000000 +migration3,id=91761A,s2_cell_id=141d9e4 lat=20.75517,lon=26.46067 1554472800000000000 +migration3,id=91761A,s2_cell_id=141e1c4 lat=20.3645,lon=27.42183 1554451200000000000 +migration3,id=91761A,s2_cell_id=1693324 lat=14.39117,lon=30.7105 1554148800000000000 +migration3,id=91761A,s2_cell_id=1694714 lat=15.33133,lon=30.03733 1554181200000000000 +migration3,id=91761A,s2_cell_id=1696fac lat=16.34217,lon=29.57933 1554192000000000000 +migration3,id=91761A,s2_cell_id=16977d4 lat=17.17033,lon=29.5845 1554213600000000000 +migration3,id=91761A,s2_cell_id=16992a4 lat=18.50633,lon=29.44517 1554300000000000000 +migration3,id=91761A,s2_cell_id=169939c lat=18.313,lon=29.7385 1554278400000000000 +migration3,id=91761A,s2_cell_id=1699a74 lat=17.84,lon=29.69133 1554267600000000000 +migration3,id=91761A,s2_cell_id=1699b74 lat=17.60133,lon=29.58417 1554235200000000000 +migration3,id=91761A,s2_cell_id=169f1ec lat=18.98767,lon=29.33583 1554354000000000000 +migration3,id=91761A,s2_cell_id=169f32c lat=18.62167,lon=29.20383 1554321600000000000 +migration3,id=91761A,s2_cell_id=169f72c lat=19.182,lon=29.00267 1554364800000000000 +migration3,id=91761A,s2_cell_id=16a0424 lat=19.7845,lon=28.2055 1554386400000000000 +migration3,id=91761A,s2_cell_id=16a084c lat=20.12833,lon=27.74283 1554440400000000000 +migration3,id=91761A,s2_cell_id=16a08bc lat=19.918,lon=27.873 1554408000000000000 +migration3,id=91761A,s2_cell_id=16e2364 lat=9.71217,lon=30.5255 1553544000000000000 +migration3,id=91761A,s2_cell_id=16ec574 lat=13.58367,lon=30.71417 1554127200000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.687,lon=30.63533 1553587200000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.6875,lon=30.635 1553608800000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.69467,lon=30.634 1553630400000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.68383,lon=30.638 1553662800000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.68583,lon=30.63383 1553695200000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.69467,lon=30.63633 1553716800000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.68567,lon=30.635 1553749200000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.68567,lon=30.63517 1553781600000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.69567,lon=30.63317 1553803200000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.69367,lon=30.6495 1553846400000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.69617,lon=30.63 1553868000000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.6965,lon=30.631 1553889600000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.69383,lon=30.63533 1553922000000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.68533,lon=30.63883 1553932800000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.69633,lon=30.6315 1553954400000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.69767,lon=30.63267 1553976000000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.69367,lon=30.64917 1554008400000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.68567,lon=30.63517 1554019200000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.68567,lon=30.63517 1554040800000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.6935,lon=30.63567 1554062400000000000 +migration3,id=91761A,s2_cell_id=16ee934 lat=12.68633,lon=30.63967 1554094800000000000 +migration3,id=91761A,s2_cell_id=16ee944 lat=12.72883,lon=30.61567 1553673600000000000 +migration3,id=91761A,s2_cell_id=16ee944 lat=12.74033,lon=30.60267 1554105600000000000 +migration3,id=91761A,s2_cell_id=16ee94c lat=12.707,lon=30.61717 1553760000000000000 +migration3,id=91761A,s2_cell_id=16ee94c lat=12.70783,lon=30.619 1553835600000000000 +migration3,id=91761A,s2_cell_id=16ef414 lat=11.975,lon=30.45917 1553576400000000000 +migration3,id=91761A,s2_cell_id=170d92c lat=5.1515,lon=31.82783 1553490000000000000 +migration3,id=91761A,s2_cell_id=171a85c lat=6.80267,lon=30.998 1553500800000000000 +migration3,id=91761A,s2_cell_id=171c424 lat=8.241,lon=30.6485 1553522400000000000 +migration3,id=91761A,s2_cell_id=177a6cc lat=1.88433,lon=32.69133 1553457600000000000 +migration3,id=91761A,s2_cell_id=177fbac lat=0.34317,lon=33.98167 1553436000000000000 +migration3,id=91761A,s2_cell_id=177fcdc lat=0.12467,lon=33.88033 1546545600000000000 +migration3,id=91761A,s2_cell_id=177fcdc lat=0.12667,lon=33.88117 1546718400000000000 +migration3,id=91761A,s2_cell_id=177fcdc lat=0.13017,lon=33.8825 1546804800000000000 +migration3,id=91761A,s2_cell_id=177fce4 lat=0.12783,lon=33.89283 1550779200000000000 +migration3,id=91761A,s2_cell_id=177fcfc lat=0.14467,lon=33.93433 1546318800000000000 +migration3,id=91761A,s2_cell_id=177fd04 lat=0.122,lon=33.9335 1550822400000000000 +migration3,id=91761A,s2_cell_id=177fd0c lat=0.07733,lon=33.9355 1547193600000000000 +migration3,id=91761A,s2_cell_id=177fd0c lat=0.06983,lon=33.93083 1547366400000000000 +migration3,id=91761A,s2_cell_id=177fd0c lat=0.0655,lon=33.93117 1548316800000000000 +migration3,id=91761A,s2_cell_id=177fd0c lat=0.06417,lon=33.937 1553241600000000000 +migration3,id=91761A,s2_cell_id=177fd14 lat=0.0725,lon=33.9225 1546610400000000000 +migration3,id=91761A,s2_cell_id=177fd14 lat=0.07,lon=33.92383 1546869600000000000 +migration3,id=91761A,s2_cell_id=177fd14 lat=0.06383,lon=33.92567 1548230400000000000 +migration3,id=91761A,s2_cell_id=177fd14 lat=0.06567,lon=33.88783 1548738000000000000 +migration3,id=91761A,s2_cell_id=177fd14 lat=0.062,lon=33.89783 1549774800000000000 +migration3,id=91761A,s2_cell_id=177fd14 lat=0.064,lon=33.905 1549947600000000000 +migration3,id=91761A,s2_cell_id=177fd14 lat=0.06467,lon=33.93017 1550520000000000000 +migration3,id=91761A,s2_cell_id=177fd14 lat=0.07033,lon=33.9225 1553328000000000000 +migration3,id=91761A,s2_cell_id=177fd24 lat=0.12317,lon=33.8835 1546372800000000000 +migration3,id=91761A,s2_cell_id=177fd24 lat=0.11883,lon=33.87983 1546459200000000000 +migration3,id=91761A,s2_cell_id=177fd24 lat=0.12,lon=33.88317 1546632000000000000 +migration3,id=91761A,s2_cell_id=177fd24 lat=0.11617,lon=33.88333 1550811600000000000 +migration3,id=91761A,s2_cell_id=177fd44 lat=0.0385,lon=33.88083 1546675200000000000 +migration3,id=91761A,s2_cell_id=177fd44 lat=0.05167,lon=33.8795 1546750800000000000 +migration3,id=91761A,s2_cell_id=177fd44 lat=0.033,lon=33.85633 1547107200000000000 +migration3,id=91761A,s2_cell_id=177fd4c lat=0.042,lon=33.8195 1547701200000000000 +migration3,id=91761A,s2_cell_id=177fd54 lat=0.01417,lon=33.82983 1547280000000000000 +migration3,id=91761A,s2_cell_id=177fd54 lat=0.01583,lon=33.834 1551340800000000000 +migration3,id=91761A,s2_cell_id=177fd54 lat=0.02417,lon=33.82583 1551427200000000000 +migration3,id=91761A,s2_cell_id=177fd5c lat=0.02083,lon=33.87583 1547269200000000000 +migration3,id=91761A,s2_cell_id=177fd5c lat=0.02517,lon=33.8415 1551416400000000000 +migration3,id=91761A,s2_cell_id=177fd64 lat=0.0295,lon=33.92117 1548910800000000000 +migration3,id=91761A,s2_cell_id=177fd64 lat=0.02933,lon=33.91283 1549170000000000000 +migration3,id=91761A,s2_cell_id=177fd64 lat=0.02833,lon=33.91217 1549180800000000000 +migration3,id=91761A,s2_cell_id=177fd64 lat=0.0215,lon=33.90767 1551070800000000000 +migration3,id=91761A,s2_cell_id=177fd64 lat=0.01767,lon=33.9145 1551157200000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.05067,lon=33.892 1546502400000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.06,lon=33.9 1546578000000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.05367,lon=33.89017 1546588800000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.03417,lon=33.8905 1546848000000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.06183,lon=33.89783 1546934400000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.05867,lon=33.90017 1547010000000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.05933,lon=33.888 1547096400000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.03517,lon=33.923 1547884800000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.05517,lon=33.90567 1548489600000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.05533,lon=33.89983 1548662400000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.03583,lon=33.92733 1548748800000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.04367,lon=33.92533 1548835200000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.03483,lon=33.91533 1548997200000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.03567,lon=33.92433 1549094400000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.04683,lon=33.914 1549688400000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.041,lon=33.91217 1550206800000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.0365,lon=33.91433 1550379600000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.03333,lon=33.90883 1550466000000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.044,lon=33.91017 1550908800000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.04367,lon=33.92067 1550995200000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.04833,lon=33.9155 1553068800000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.04517,lon=33.91783 1553144400000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.0465,lon=33.92967 1553317200000000000 +migration3,id=91761A,s2_cell_id=177fd6c lat=0.059,lon=33.92717 1553371200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0515,lon=33.94933 1546329600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05167,lon=33.94933 1546351200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05133,lon=33.94933 1546405200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05167,lon=33.94933 1546416000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05433,lon=33.94733 1546437600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.059,lon=33.946 1546491600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0545,lon=33.94733 1546524000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05867,lon=33.94633 1546664400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05133,lon=33.94933 1546696800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.059,lon=33.94617 1546761600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05433,lon=33.94733 1546783200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05233,lon=33.9505 1546837200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05867,lon=33.94633 1546891200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05267,lon=33.95067 1546923600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05133,lon=33.94933 1546956000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05867,lon=33.94617 1546977600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.059,lon=33.94633 1547020800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05867,lon=33.94633 1547042400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05867,lon=33.94617 1547064000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0515,lon=33.94933 1547128800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05867,lon=33.94633 1547150400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05867,lon=33.94633 1547182800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0545,lon=33.94733 1547215200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05883,lon=33.94617 1547236800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05867,lon=33.94633 1547323200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.04483,lon=33.96983 1547355600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0515,lon=33.94933 1547388000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05867,lon=33.94633 1547409600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0515,lon=33.9495 1547442000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.03817,lon=33.94617 1547452800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0515,lon=33.94933 1547474400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05883,lon=33.94633 1547496000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0515,lon=33.94933 1547528400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05167,lon=33.94933 1547539200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05733,lon=33.94733 1547560800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.04083,lon=33.9545 1547582400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05733,lon=33.94683 1547614800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05683,lon=33.947 1547625600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0515,lon=33.94933 1547647200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.059,lon=33.94617 1547668800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.059,lon=33.94617 1547733600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.94717 1547755200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.03617,lon=33.9605 1547787600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05267,lon=33.95067 1547798400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05717,lon=33.94733 1547820000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.947 1547841600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.057,lon=33.94733 1547874000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0575,lon=33.9475 1547906400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.947 1547928000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0575,lon=33.9475 1547960400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05733,lon=33.9475 1547971200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05733,lon=33.94733 1547992800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05917,lon=33.94633 1548014400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.052,lon=33.93833 1548046800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05733,lon=33.9475 1548057600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05733,lon=33.9475 1548079200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05383,lon=33.947 1548100800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0575,lon=33.9475 1548133200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05267,lon=33.95067 1548144000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0515,lon=33.94933 1548165600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.94717 1548187200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05267,lon=33.95067 1548219600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05733,lon=33.9475 1548252000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05917,lon=33.94633 1548273600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05767,lon=33.94683 1548306000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.057,lon=33.94733 1548338400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.947 1548360000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05733,lon=33.9475 1548392400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0575,lon=33.9475 1548403200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05767,lon=33.94683 1548424800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.947 1548446400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0575,lon=33.94767 1548478800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0575,lon=33.9475 1548511200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.947 1548532800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.03817,lon=33.945 1548565200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05317,lon=33.94783 1548576000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05733,lon=33.94733 1548597600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.947 1548619200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.04117,lon=33.94583 1548651600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05717,lon=33.94733 1548684000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05333,lon=33.94767 1548705600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0575,lon=33.9475 1548770400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.94717 1548792000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.057,lon=33.947 1548824400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.057,lon=33.94733 1548856800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05317,lon=33.94783 1548878400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05833,lon=33.946 1548921600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05333,lon=33.94783 1548943200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05317,lon=33.94767 1548964800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05317,lon=33.94783 1549008000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0575,lon=33.94733 1549029600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.053,lon=33.94783 1549051200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.041,lon=33.93333 1549083600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05317,lon=33.94783 1549116000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.94717 1549137600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.947 1549202400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.04183,lon=33.94317 1549224000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05233,lon=33.9505 1549256400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05283,lon=33.95083 1549267200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.94717 1549288800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.94717 1549310400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05183,lon=33.943 1549342800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.04517,lon=33.95067 1549353600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.947 1549375200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0515,lon=33.949 1549396800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0355,lon=33.97133 1549429200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05333,lon=33.94767 1549461600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05167,lon=33.94833 1549483200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05167,lon=33.9485 1549515600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05317,lon=33.94767 1549526400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.053,lon=33.94767 1549548000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05283,lon=33.94783 1549569600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05167,lon=33.94833 1549602000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05833,lon=33.94583 1549612800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.059,lon=33.94633 1549634400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.053,lon=33.948 1549656000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05833,lon=33.946 1549699200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.059,lon=33.94633 1549720800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05283,lon=33.94783 1549742400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05683,lon=33.947 1549785600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0605,lon=33.945 1549807200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.053,lon=33.948 1549828800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0515,lon=33.94833 1549861200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05267,lon=33.94783 1549872000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05167,lon=33.94933 1549893600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0515,lon=33.94917 1549915200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05683,lon=33.947 1549958400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05383,lon=33.947 1549980000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0515,lon=33.94833 1550001600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0525,lon=33.95017 1550034000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.04317,lon=33.95283 1550044800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.059,lon=33.94633 1550066400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05383,lon=33.947 1550088000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.03817,lon=33.95617 1550120400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.032,lon=33.94433 1550131200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.94733 1550152800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05383,lon=33.94683 1550174400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05733,lon=33.947 1550217600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05683,lon=33.947 1550239200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05167,lon=33.9485 1550260800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.03217,lon=33.967 1550293200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0525,lon=33.95017 1550304000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05167,lon=33.9495 1550325600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05167,lon=33.9485 1550347200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05183,lon=33.9495 1550390400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05917,lon=33.94633 1550412000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05167,lon=33.94833 1550433600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05683,lon=33.947 1550476800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.059,lon=33.94633 1550498400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05717,lon=33.94717 1550552400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.059,lon=33.94633 1550584800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.055,lon=33.94117 1550606400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05717,lon=33.94717 1550638800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.057,lon=33.947 1550649600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05883,lon=33.9465 1550671200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05917,lon=33.94633 1550692800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.03967,lon=33.95667 1550725200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.057,lon=33.947 1550736000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.057,lon=33.947 1550757600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05683,lon=33.94733 1550844000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05917,lon=33.94633 1550865600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05717,lon=33.947 1550898000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.057,lon=33.947 1550930400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05917,lon=33.94633 1550952000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.04617,lon=33.93783 1550984400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.057,lon=33.947 1551016800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05917,lon=33.94633 1551038400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.04067,lon=33.93167 1551081600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05917,lon=33.9465 1551103200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05933,lon=33.94633 1551124800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0575,lon=33.9475 1551168000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05183,lon=33.9495 1551189600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05933,lon=33.94633 1551211200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05367,lon=33.9475 1551276000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05183,lon=33.9485 1551297600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05933,lon=33.94633 1551384000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05167,lon=33.9485 1551330000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.059,lon=33.94633 1551362400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0595,lon=33.9465 1553025600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.057,lon=33.947 1553058000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05717,lon=33.94717 1553090400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0595,lon=33.9465 1553112000000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05667,lon=33.947 1553155200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05783,lon=33.9465 1553176800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05967,lon=33.9465 1553198400000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05667,lon=33.94717 1553230800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05983,lon=33.94667 1553263200000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05983,lon=33.9465 1553284800000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.0585,lon=33.94617 1553349600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05133,lon=33.95017 1553403600000000000 +migration3,id=91761A,s2_cell_id=177fd74 lat=0.05717,lon=33.947 1553414400000000000 +migration3,id=91761A,s2_cell_id=177fd7c lat=0.027,lon=33.9415 1549440000000000000 +migration3,id=91761A,s2_cell_id=177fd7c lat=0.03083,lon=33.96617 1550563200000000000 +migration3,id=91761A,s2_cell_id=19d483c lat=-0.954,lon=33.91517 1551945600000000000 +migration3,id=91761A,s2_cell_id=19d4844 lat=-0.93283,lon=33.907 1551762000000000000 +migration3,id=91761A,s2_cell_id=19d4844 lat=-0.92933,lon=33.893 1551772800000000000 +migration3,id=91761A,s2_cell_id=19d4844 lat=-0.94033,lon=33.91 1551859200000000000 +migration3,id=91761A,s2_cell_id=19d488c lat=-0.87867,lon=33.86833 1552982400000000000 +migration3,id=91761A,s2_cell_id=19d48e4 lat=-0.84483,lon=33.95267 1552021200000000000 +migration3,id=91761A,s2_cell_id=19d48ec lat=-0.8445,lon=33.92367 1552464000000000000 +migration3,id=91761A,s2_cell_id=19d48ec lat=-0.84567,lon=33.89667 1552550400000000000 +migration3,id=91761A,s2_cell_id=19d48f4 lat=-0.85033,lon=33.91533 1552539600000000000 +migration3,id=91761A,s2_cell_id=19d48f4 lat=-0.86633,lon=33.922 1552723200000000000 +migration3,id=91761A,s2_cell_id=19d48f4 lat=-0.86783,lon=33.9 1552809600000000000 +migration3,id=91761A,s2_cell_id=19d48f4 lat=-0.878,lon=33.89033 1552896000000000000 +migration3,id=91761A,s2_cell_id=19d48f4 lat=-0.861,lon=33.91217 1552971600000000000 +migration3,id=91761A,s2_cell_id=19d48fc lat=-0.84833,lon=33.93717 1552032000000000000 +migration3,id=91761A,s2_cell_id=19d48fc lat=-0.86417,lon=33.97317 1552107600000000000 +migration3,id=91761A,s2_cell_id=19d48fc lat=-0.85683,lon=33.95017 1552118400000000000 +migration3,id=91761A,s2_cell_id=19d48fc lat=-0.86133,lon=33.97267 1552204800000000000 +migration3,id=91761A,s2_cell_id=19d48fc lat=-0.85917,lon=33.96 1552453200000000000 +migration3,id=91761A,s2_cell_id=19d4904 lat=-0.868,lon=33.99933 1552194000000000000 +migration3,id=91761A,s2_cell_id=19d4904 lat=-0.86867,lon=33.991 1552291200000000000 +migration3,id=91761A,s2_cell_id=19d4904 lat=-0.86433,lon=33.98517 1552366800000000000 +migration3,id=91761A,s2_cell_id=19d4904 lat=-0.853,lon=33.97583 1552377600000000000 +migration3,id=91761A,s2_cell_id=19d497c lat=-0.87617,lon=34.1255 1552680000000000000 +migration3,id=91761A,s2_cell_id=19d497c lat=-0.87483,lon=34.132 1552766400000000000 +migration3,id=91761A,s2_cell_id=19d497c lat=-0.84533,lon=34.128 1552852800000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90433,lon=34.143 1551535200000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.8925,lon=34.12933 1551556800000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90517,lon=34.14283 1551621600000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90567,lon=34.14283 1551643200000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90583,lon=34.14283 1551675600000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90433,lon=34.14317 1551686400000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90383,lon=34.1435 1551708000000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.88467,lon=34.11367 1551729600000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90383,lon=34.1435 1551794400000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.89633,lon=34.13133 1551816000000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.907,lon=34.14317 1551848400000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90517,lon=34.14283 1551880800000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.905,lon=34.14283 1551902400000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.904,lon=34.14333 1551967200000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.89833,lon=34.13667 1551988800000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90483,lon=34.14283 1552053600000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.89,lon=34.13567 1552075200000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90433,lon=34.143 1552140000000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90417,lon=34.14317 1552161600000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.904,lon=34.14333 1552226400000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.89267,lon=34.1235 1552248000000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90417,lon=34.14333 1552312800000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.89083,lon=34.12783 1552334400000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.904,lon=34.14333 1552399200000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.88533,lon=34.13217 1552420800000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.904,lon=34.14333 1552485600000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.888,lon=34.1405 1552507200000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90417,lon=34.14333 1552572000000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.88433,lon=34.12283 1552593600000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.904,lon=34.14333 1552658400000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.9035,lon=34.14383 1552712400000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.904,lon=34.14333 1552744800000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.9035,lon=34.14383 1552798800000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90467,lon=34.14283 1552831200000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90333,lon=34.144 1552885200000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.90417,lon=34.14333 1552917600000000000 +migration3,id=91761A,s2_cell_id=19d4984 lat=-0.87983,lon=34.13867 1552939200000000000 +migration3,id=91761A,s2_cell_id=19d49a4 lat=-0.884,lon=34.02817 1552280400000000000 +migration3,id=91761A,s2_cell_id=19d49ac lat=-0.88583,lon=34.00333 1551934800000000000 +migration3,id=91761A,s2_cell_id=19d49ac lat=-0.87967,lon=34.0075 1552626000000000000 +migration3,id=91761A,s2_cell_id=19d49ac lat=-0.89017,lon=33.99583 1552636800000000000 +migration3,id=91761A,s2_cell_id=19d49b4 lat=-0.9115,lon=34.01917 1551589200000000000 +migration3,id=91761A,s2_cell_id=19d49dc lat=-0.988,lon=34.0385 1551600000000000000 +migration3,id=91761A,s2_cell_id=19d4e04 lat=-0.50533,lon=34.12883 1551470400000000000 +migration3,id=91761A,s2_cell_id=19d4e54 lat=-0.62267,lon=33.99333 1551502800000000000 +migration3,id=91761A,s2_cell_id=19d4f2c lat=-0.72417,lon=33.94417 1551513600000000000 +migration3,id=91761A,s2_cell_id=19d5634 lat=-0.061,lon=34.0085 1551254400000000000 +migration3,id=91761A,s2_cell_id=19d5634 lat=-0.0475,lon=33.99333 1551330000000000000 +migration3,id=91761A,s2_cell_id=19d5634 lat=-0.06117,lon=33.97883 1551340800000000000 +migration3,id=91761A,s2_cell_id=19d5634 lat=-0.046,lon=34.00633 1551362400000000000 +migration3,id=91761A,s2_cell_id=19d568c lat=-0.14483,lon=34.08133 1553004000000000000 +migration3,id=91761A,s2_cell_id=19d5774 lat=-0.145,lon=33.87783 1547301600000000000 +migration3,id=91761A,s2_cell_id=19d57a4 lat=-0.0965,lon=33.9005 1551448800000000000 +migration3,id=91761A,s2_cell_id=19d57c4 lat=-0.04367,lon=33.91083 1551243600000000000 +migration3,id=91761A,s2_cell_id=19d590c lat=-0.16683,lon=33.58783 1547712000000000000 +migration3,id=91763A,s2_cell_id=19d1924 lat=-1.76517,lon=32.897 1559073600000000000 +migration3,id=91763A,s2_cell_id=19d22d4 lat=-1.53267,lon=33.24083 1559106000000000000 +migration3,id=91763A,s2_cell_id=19d23ec lat=-1.7405,lon=33.37783 1559052000000000000 +migration3,id=91763A,s2_cell_id=19d23f4 lat=-1.72767,lon=33.41917 1556006400000000000 +migration3,id=91763A,s2_cell_id=19d23f4 lat=-1.73167,lon=33.421 1556028000000000000 +migration3,id=91763A,s2_cell_id=19d30a4 lat=-1.32267,lon=33.841 1547442000000000000 +migration3,id=91763A,s2_cell_id=19d30a4 lat=-1.32167,lon=33.816 1550044800000000000 +migration3,id=91763A,s2_cell_id=19d30a4 lat=-1.31817,lon=33.81083 1551416400000000000 +migration3,id=91763A,s2_cell_id=19d30a4 lat=-1.318,lon=33.81083 1551448800000000000 +migration3,id=91763A,s2_cell_id=19d30a4 lat=-1.3095,lon=33.82717 1551859200000000000 +migration3,id=91763A,s2_cell_id=19d30a4 lat=-1.314,lon=33.8175 1552226400000000000 +migration3,id=91763A,s2_cell_id=19d30a4 lat=-1.31833,lon=33.81083 1552896000000000000 +migration3,id=91763A,s2_cell_id=19d30a4 lat=-1.32317,lon=33.83917 1553058000000000000 +migration3,id=91763A,s2_cell_id=19d30a4 lat=-1.2985,lon=33.80367 1555920000000000000 +migration3,id=91763A,s2_cell_id=19d30a4 lat=-1.3165,lon=33.81317 1556611200000000000 +migration3,id=91763A,s2_cell_id=19d30a4 lat=-1.30567,lon=33.8185 1559019600000000000 +migration3,id=91763A,s2_cell_id=19d30a4 lat=-1.305,lon=33.8185 1574409600000000000 +migration3,id=91763A,s2_cell_id=19d30ac lat=-1.2745,lon=33.8205 1547971200000000000 +migration3,id=91763A,s2_cell_id=19d30ac lat=-1.27583,lon=33.8255 1549180800000000000 +migration3,id=91763A,s2_cell_id=19d30ac lat=-1.27583,lon=33.8135 1569830400000000000 +migration3,id=91763A,s2_cell_id=19d30ac lat=-1.28133,lon=33.8025 1574236800000000000 +migration3,id=91763A,s2_cell_id=19d30ac lat=-1.27817,lon=33.83133 1574841600000000000 +migration3,id=91763A,s2_cell_id=19d30ac lat=-1.274,lon=33.826 1575003600000000000 +migration3,id=91763A,s2_cell_id=19d30ac lat=-1.27267,lon=33.79833 1576310400000000000 +migration3,id=91763A,s2_cell_id=19d30b4 lat=-1.28367,lon=33.86383 1553576400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85067 1546696800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85367 1546923600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32167,lon=33.8535 1547128800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85317 1547452800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85117 1547474400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.8535 1547560800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85317 1547820000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85317 1547874000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.8535 1547884800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.8535 1547906400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85367 1547960400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3205,lon=33.85483 1547992800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32117,lon=33.85367 1548046800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32117,lon=33.85367 1548057600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.8535 1548079200000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32117,lon=33.85367 1548133200000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31283,lon=33.84317 1548144000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.321,lon=33.85383 1548165600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31183,lon=33.85283 1548219600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32217,lon=33.85133 1548230400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32167,lon=33.85067 1548252000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85067 1548306000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.322,lon=33.85133 1548316800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85367 1548338400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31183,lon=33.85267 1548478800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85367 1548489600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32033,lon=33.85483 1548511200000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31183,lon=33.85267 1548565200000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85317 1548576000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.8535 1548597600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.321,lon=33.85367 1548651600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32167,lon=33.85067 1548662400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85317 1548684000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85067 1548738000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85367 1548748800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85317 1548770400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3165,lon=33.86017 1548910800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85317 1548921600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85317 1548943200000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85317 1549202400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3095,lon=33.85383 1549256400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85367 1549267200000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.8505 1549288800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.8535 1549375200000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85317 1549612800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32167,lon=33.85317 1549634400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85067 1550066400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85067 1550206800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32167,lon=33.85067 1550217600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31317,lon=33.85183 1550239200000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31817,lon=33.851 1550293200000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.8535 1550304000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85367 1550325600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3165,lon=33.86017 1550379600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32117,lon=33.85367 1550390400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85317 1550412000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.8535 1550584800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31617,lon=33.86083 1551157200000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.8535 1551168000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31617,lon=33.86067 1551189600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31233,lon=33.8525 1551362400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85367 1551535200000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32167,lon=33.85067 1551600000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32117,lon=33.85367 1551621600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3165,lon=33.86017 1551675600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32167,lon=33.8505 1551686400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3215,lon=33.85067 1551762000000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85367 1551772800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.32183,lon=33.85367 1551794400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31583,lon=33.86083 1551848400000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31633,lon=33.86083 1551880800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31617,lon=33.86067 1551934800000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31583,lon=33.86083 1551945600000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.31617,lon=33.86083 1551967200000000000 +migration3,id=91763A,s2_cell_id=19d30bc lat=-1.3165,lon=33.86017 1552021200000000000 + diff --git a/integrations/influxdb_connector/sample-influxdb-clients/go/README.md b/integrations/influxdb_connector/sample-influxdb-clients/go/README.md new file mode 100644 index 00000000..69007b1d --- /dev/null +++ b/integrations/influxdb_connector/sample-influxdb-clients/go/README.md @@ -0,0 +1,43 @@ +# InfluxDB Timestream Connector Sample Client + +## Overview + +This directory provides a sample Go client for use with the [InfluxDB Timestream Connector](../../influxdb-timestream-connector/README.md). + +By default, the client reads data from `../data/bird-migration.line` and sends [line protocol data](https://docs.influxdata.com/influxdb/v2/reference/syntax/line-protocol/) to the InfluxDB Timestream Connector. The client authenticates all requests with [AWS Signature Version 4](https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html) (SigV4) authentication. Its code provides an example of how SigV4 authentication can be implemented, since, when deployed as part of a CloudFormation stack, the InfluxDB Timestream Connector requires all requests use SigV4 authentication. + +## Configuration + +### Prerequisites + +1. [Install Go](https://go.dev/doc/install). +2. [Configure credentials to be used for SigV4 authentication](https://docs.aws.amazon.com/sdkref/latest/guide/creds-config-files.html). +3. [Deploy the InfluxDB Timestream Connector](../../influxdb-timestream-connector/README.md#deployment-options). + +### Command-Line Options + +The following command-line options are available: + +- `dataset`: The path to the line protocol dataset being ingested. Defaults to `../data/bird-migration.line`. +- `endpoint`: Endpoint for InfluxDB Timestream Connector. Defaults to `http://127.0.0.1:9000`. +- `precision`: Precision for line protocol: nanoseconds=`ns`, milliseconds=`ms`, microseconds=`us`, seconds=`s`. Defaults to `ns`. +- `region`: AWS region for InfluxDB Timestream Connector. Defaults to `us-east-1`. +- `service`: Service value for SigV4 header. Defaults to `lambda`. + +## Example + +### With Connector Deployed in a CloudFormation Stack + +When the connector is [deployed as a Lambda function within a CloudFormation stack](../../influxdb-timestream-connector/README.md#aws-cloudformation-deployment), run the following, replacing `` with the AWS region you deployed your stack in and `` with the endpoint of your deployed REST API Gateway: + +```shell +go run line-protocol-client-demo.go --region --service execute-api --endpoint +``` + +### With the Connector Deployed Locally + +When the connector is [deployed locally](../../influxdb-timestream-connector/README.md#local-deployment), the default command-line option values suffice: + +```shell +go run line-protocol-client-demo.go +``` diff --git a/integrations/influxdb_connector/sample-influxdb-clients/go/go.mod b/integrations/influxdb_connector/sample-influxdb-clients/go/go.mod new file mode 100644 index 00000000..d07f4dcf --- /dev/null +++ b/integrations/influxdb_connector/sample-influxdb-clients/go/go.mod @@ -0,0 +1,29 @@ +module line-protocol-client-demo + +go 1.22.1 + +require ( + github.com/aws/aws-sdk-go-v2 v1.27.1 + github.com/aws/aws-sdk-go-v2/config v1.27.17 + github.com/influxdata/influxdb-client-go/v2 v2.3.1-0.20210518120617-5d1fff431040 +) + +require ( + github.com/aws/aws-sdk-go-v2/credentials v1.17.17 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.4 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.20.10 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.28.11 // indirect + github.com/aws/smithy-go v1.20.2 // indirect + github.com/deepmap/oapi-codegen v1.6.0 // indirect + github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/stretchr/testify v1.8.1 // indirect + golang.org/x/net v0.23.0 // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect +) diff --git a/integrations/influxdb_connector/sample-influxdb-clients/go/go.sum b/integrations/influxdb_connector/sample-influxdb-clients/go/go.sum new file mode 100644 index 00000000..24d6f5c4 --- /dev/null +++ b/integrations/influxdb_connector/sample-influxdb-clients/go/go.sum @@ -0,0 +1,119 @@ +github.com/aws/aws-sdk-go-v2 v1.27.1 h1:xypCL2owhog46iFxBKKpBcw+bPTX/RJzwNj8uSilENw= +github.com/aws/aws-sdk-go-v2 v1.27.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2/config v1.27.17 h1:L0JZN7Gh7pT6u5CJReKsLhGKparqNKui+mcpxMXjDZc= +github.com/aws/aws-sdk-go-v2/config v1.27.17/go.mod h1:MzM3balLZeaafYcPz8IihAmam/aCz6niPQI0FdprxW0= +github.com/aws/aws-sdk-go-v2/credentials v1.17.17 h1:b3Dk9uxQByS9sc6r0sc2jmxsJKO75eOcb9nNEiaUBLM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.17/go.mod h1:e4khg9iY08LnFK/HXQDWMf9GDaiMari7jWPnXvKAuBU= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.4 h1:0cSfTYYL9qiRcdi4Dvz+8s3JUgNR2qvbgZkXcwPEEEk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.4/go.mod h1:Wjn5O9eS7uSi7vlPKt/v0MLTncANn9EMmoDvnzJli6o= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.8 h1:RnLB7p6aaFMRfyQkD6ckxR7myCC9SABIqSz4czYUUbU= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.8/go.mod h1:XH7dQJd+56wEbP1I4e4Duo+QhSMxNArE8VP7NuUOTeM= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.8 h1:jzApk2f58L9yW9q1GEab3BMMFWUkkiZhyrRUtbwUbKU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.8/go.mod h1:WqO+FftfO3tGePUtQxPXM6iODVfqMwsVMgTbG/ZXIdQ= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.10 h1:7kZqP7akv0enu6ykJhb9OYlw16oOrSy+Epus8o/VqMY= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.10/go.mod h1:gYVF3nM1ApfTRDj9pvdhootBb8WbiIejuqn4w8ruMes= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.10 h1:ItKVmFwbyb/ZnCWf+nu3XBVmUirpO9eGEQd7urnBA0s= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.10/go.mod h1:5XKooCTi9VB/xZmJDvh7uZ+v3uQ7QdX6diOyhvPA+/w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.4 h1:QMSCYDg3Iyls0KZc/dk3JtS2c1lFfqbmYO10qBPPkJk= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.4/go.mod h1:MZ/PVYU/mRbmSF6WK3ybCYHjA2mig8utVokDEVLDgE0= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.11 h1:HYS0csS7UJxdYRoG+bGgUYrSwVnV3/ece/wHm90TApM= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.11/go.mod h1:QXnthRM35zI92048MMwfFChjFmoufTdhtHmouwNfhhU= +github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= +github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiDR1gg0= +github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/getkin/kin-openapi v0.53.0/go.mod h1:7Yn5whZr5kJi6t+kShccXS8ae1APpYTW6yheSwk8Yi4= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/golangci/lint-1 v0.0.0-20181222135242-d2cdd8c08219/go.mod h1:/X8TswGSh1pIozq4ZwCfxS0WA5JGXguxk94ar/4c87Y= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/influxdata/influxdb-client-go/v2 v2.3.1-0.20210518120617-5d1fff431040 h1:MBLCfcSsUyFPDJp6T7EoHp/Ph3Jkrm4EuUKLD2rUWHg= +github.com/influxdata/influxdb-client-go/v2 v2.3.1-0.20210518120617-5d1fff431040/go.mod h1:vLNHdxTJkIf2mSLvGrpj8TCcISApPoXkaxP8g9uRlW8= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/labstack/echo/v4 v4.2.1/go.mod h1:AA49e0DZ8kk5jTOOCKNuPR6oTnBS0dYiM4FW1e6jwpg= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= +github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integrations/influxdb_connector/sample-influxdb-clients/go/line-protocol-client-demo.go b/integrations/influxdb_connector/sample-influxdb-clients/go/line-protocol-client-demo.go new file mode 100644 index 00000000..85ef1800 --- /dev/null +++ b/integrations/influxdb_connector/sample-influxdb-clients/go/line-protocol-client-demo.go @@ -0,0 +1,117 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "flag" + "fmt" + "io" + "net/http" + "time" + "os" + "bufio" + + influxdbhttp "github.com/influxdata/influxdb-client-go/v2/api/http" + "github.com/influxdata/influxdb-client-go/v2" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/aws/signer/v4" +) + +// UserAgentSetter is the implementation of Doer interface for setting the SigV4 headers +type SigV4HeaderSetter struct { + RequestDoer influxdbhttp.Doer +} + +var( + region string + service string + endpoint string + dataset string + precision string +) + +func main() { + flag.StringVar(®ion, "region", "us-east-1", "AWS region for InfluxDB Timestream Connector") + flag.StringVar(&service, "service", "lambda", "Service value for SigV4 header") + flag.StringVar(&endpoint, "endpoint", "http://127.0.0.1:9000", "Endpoint for InfluxDB Timestream Connector") + flag.StringVar(&dataset, "dataset", "../data/bird-migration.line", "Line protocol dataset being ingested") + flag.StringVar(&precision, "precision", "ns", "Precision for line protocol: nanoseconds=ns, milliseconds=ms, microseconds=us, seconds=s") + flag.Parse() + + opts := influxdb2.DefaultOptions() + opts.HTTPOptions().SetHTTPDoer(&SigV4HeaderSetter{RequestDoer: opts.HTTPClient(),}) + + switch { + case precision == "ns": + opts.WriteOptions().SetPrecision(time.Nanosecond) + case precision == "ms": + opts.WriteOptions().SetPrecision(time.Millisecond) + case precision == "us": + opts.WriteOptions().SetPrecision(time.Microsecond) + case precision == "s": + opts.WriteOptions().SetPrecision(time.Second) + default: + fmt.Println("Invalid precision value, valid values include: nanoseconds=ns, milliseconds=ms, microseconds=us, seconds=s") + return + } + + bucket := "" + org := "" + token := "" + + file, err := os.Open(dataset) + if err != nil { + fmt.Println(err) + return + } + defer file.Close() + + sc := bufio.NewScanner(file) + lines := make([]string, 0) + + for sc.Scan() { + lines = append(lines, sc.Text()) + } + + if err := sc.Err(); err != nil { + fmt.Println(err) + return + } + + client := influxdb2.NewClientWithOptions(endpoint, token, opts) + writeAPI := client.WriteAPIBlocking(org, bucket) + err = writeAPI.WriteRecord(context.Background(), lines[0:]...) + if err != nil { + panic(err) + } + + // Ensures background processes finishes + client.Close() +} + +// Do is called before each request is made +func (u *SigV4HeaderSetter) Do(req *http.Request) (*http.Response, error) { + ctx := context.Background() + signer := v4.NewSigner() + + cfg, _ := config.LoadDefaultConfig(context.TODO()) + credentialsValue, _ := cfg.Credentials.Retrieve(context.TODO()) + + bodyBytes, _ := io.ReadAll(req.Body) + reader1 := io.NopCloser(bytes.NewBuffer(bodyBytes)) + reader2 := io.NopCloser(bytes.NewBuffer(bodyBytes)) + bodyBytes, _ = io.ReadAll(reader1) + + hash := sha256.New() + hash.Write([]byte(string(bodyBytes))) + hashedAndEncodedBody := hex.EncodeToString(hash.Sum(nil)) + + signer.SignHTTP(ctx, credentialsValue, req, hashedAndEncodedBody, service, region, time.Now()) + req.Body = reader2 + + // Call original Doer to proceed with request + return u.RequestDoer.Do(req) +} + From eea8a7c1a1d480454ae405e4b154a67cfcec27eb Mon Sep 17 00:00:00 2001 From: Trevor Bonas <45324987+trevorbonas@users.noreply.github.com> Date: Tue, 1 Oct 2024 09:09:57 -0700 Subject: [PATCH 02/11] Add pre-commit hook for secrets scanning *Issue #, if available:* N/A. *Description of changes:* - A pre-commit hook has been added that uses `aws-secrets` in order to prevent secrets from being committed. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. --- .pre-commit-config.yaml | 19 +++++++++++++++++++ DEVELOPER_README.md | 17 +++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 DEVELOPER_README.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..33729c16 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/awslabs/git-secrets + rev: 5357e18bc27b42a827b6780564ea873a72ca1f01 + hooks: + - id: git-secrets + entry: /usr/bin/env + verbose: True + args: + # Using a bash script to ensure aws secrets rules is always installed. + - bash + - -c + - | + echo "Scanning with git-secrets..." + git-secrets --register-aws + if ! git-secrets --pre_commit_hook; then + echo "Error: Potential secrets detected. Please review your changes." + exit 1 + fi + echo "No secrets detected." diff --git a/DEVELOPER_README.md b/DEVELOPER_README.md new file mode 100644 index 00000000..16228f90 --- /dev/null +++ b/DEVELOPER_README.md @@ -0,0 +1,17 @@ +# Amazon Timestream Tools and Samples Developer Guide + +## Pre-Commit Hook + +A pre-commit hook configuration is provided in the `.pre-commit-config.yaml` file. + +This pre-commit hook is configured to scan for secrets with [`git-secrets`](https://github.com/awslabs/git-secrets), to make sure no secrets are committed to this repository. + +To use the pre-commit hook: + +1. Install [`pre-commit`](https://pre-commit.com/#install). +2. In the root of this repository, run; + ``` + pre-commit install + ``` +3. Now, whenever `git commit` is run, `git-secrets` will scan for secrets. + From da989ef34a03dc4285ba186ddbffd74960d6fa1e Mon Sep 17 00:00:00 2001 From: Trevor Bonas <45324987+trevorbonas@users.noreply.github.com> Date: Mon, 7 Oct 2024 08:21:58 -0700 Subject: [PATCH 03/11] Add SAM template *Issue #, if available:* N/A *Description of changes:* - SAM template `template.yml` added. - Asynchronous and synchronous invocation supported. - Documentation for how the connector can be deployed using the SAM template added. - `lambda_runtime` crate added. - `LambdaEvent` used instead of `lambda_http::Request`, in order to support more types of requests, instead of just AWS service integrations. - Tests added for handling different kinds of `queryParameters` keys in requests. - DLQ implemented for asynchronous invocation. - Integration tests changed to check the newly returned `serde_json::Value` struct. - [x] Unit tests passed. - [x] Integration tests passed. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. --- .../influxdb-timestream-connector/Cargo.lock | 87 +-- .../influxdb-timestream-connector/Cargo.toml | 5 +- .../influxdb-timestream-connector/README.md | 129 +++- .../influxdb-timestream-connector/src/lib.rs | 152 ++++- .../influxdb-timestream-connector/src/main.rs | 7 +- .../template.yml | 527 ++++++++++++++ .../tests/integration_test.rs | 642 ++++++++++-------- 7 files changed, 1131 insertions(+), 418 deletions(-) create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/template.yml diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.lock b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.lock index 806f2c74..9b13d613 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.lock +++ b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.lock @@ -492,22 +492,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "aws_lambda_events" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7319a086b79c3ff026a33a61e80f04fd3885fbb73237981ea080d21944e1cb1c" -dependencies = [ - "base64 0.22.1", - "bytes", - "http 1.1.0", - "http-body 1.0.0", - "http-serde", - "query_map", - "serde", - "serde_json", -] - [[package]] name = "backtrace" version = "0.3.73" @@ -589,9 +573,6 @@ name = "bytes" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" -dependencies = [ - "serde", -] [[package]] name = "bytes-utils" @@ -787,15 +768,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "encoding_rs" -version = "0.8.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.1" @@ -1247,9 +1219,10 @@ dependencies = [ "aws-types", "chrono", "influxdb-line-protocol", - "lambda_http", + "lambda_runtime", "rand", "serde", + "serde_json", "tokio", ] @@ -1268,33 +1241,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lambda_http" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fe279be7f89f5f72c97c3a96f45c43db8edab1007320ecc6a5741273b4d6db" -dependencies = [ - "aws_lambda_events", - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures", - "futures-util", - "http 1.1.0", - "http-body 1.0.0", - "http-body-util", - "hyper 1.4.1", - "lambda_runtime", - "mime", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "serde_urlencoded", - "tokio-stream", - "url", -] - [[package]] name = "lambda_runtime" version = "0.13.0" @@ -1407,12 +1353,6 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1598,17 +1538,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "query_map" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eab6b8b1074ef3359a863758dae650c7c0c6027927a085b7af911c8e0bf3a15" -dependencies = [ - "form_urlencoded", - "serde", - "serde_derive", -] - [[package]] name = "quote" version = "1.0.36" @@ -1914,18 +1843,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "sha1" version = "0.10.6" diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml index aac6e30f..79acf003 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml +++ b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml @@ -3,10 +3,10 @@ name = "influxdb-timestream-connector" version = "0.1.0" edition = "2021" -[dependencies.lambda_http] +[dependencies.lambda_runtime] version = "0.13.0" default-features = false -features = ["apigw_http", "anyhow", "tracing"] +features = ["anyhow", "tracing"] [dependencies] anyhow = "1.0.86" @@ -20,6 +20,7 @@ chrono = "0.4.38" influxdb-line-protocol = { git = "https://github.com/influxdata/influxdb3_core", rev = "d81f63ddc10e3cf1c28b05e6c1cef03b71da7f8a" } rand = "0.5.0" serde = { version = "1.0.208", features = ["derive"] } +serde_json = "1.0.1" tokio = { version = "1.39.3", features = ["full"] } [package.metadata.lambda.env] diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/README.md b/integrations/influxdb_connector/influxdb-timestream-connector/README.md index 905deecd..30b3bb3f 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/README.md +++ b/integrations/influxdb_connector/influxdb-timestream-connector/README.md @@ -14,7 +14,7 @@ The following diagram shows a high-level overview of the connector's architectur ### Multi-Table Multi-Measure -The following table shows how the connector maps line protocol elements to Timestream for LiveAnalytics record elements. +The following table shows how the connector maps line protocol elements to Timestream for LiveAnalytics record attributes. | Line Protocol Element | Timestream Record Attribute | |-----------------------|---------------------------| @@ -23,7 +23,7 @@ The following table shows how the connector maps line protocol elements to Times | Fields | Measures | | Measurements | Table names | -A Timestream record's `measure_name` field is not derived from any element of ingested line protocol. Due to the multi-measure record translation, the connector sets the `measure_name` for each multi-measure record to the value of a Lambda environment variable. +A Timestream record's `measure_name` field is not derived from any element of ingested line protocol. Due to the multi-measure record translation, the connector sets the `measure_name` for each multi-measure record to the value of a Lambda environment variable. When [deployed as part of a CloudFormation stack](#aws-cloudformation-deployment), this can be customized by overriding the `MeasureNameForMultiMeasureRecords` parameter. When [deployed locally](#local-deployment), this can be customized by setting the `measure_name_for_multi_measure_records` environment variable. The following example shows the translation of a single line protocol point into a Timestream for LiveAnalytics table, using a Timestamp with second precision and a Lambda environment variable configured to `influxdb-measure`: @@ -47,30 +47,70 @@ The InfluxDB Timestream connector can be deployed within an AWS CloudFormation s #### Deploying a CloudFormation Stack Using SAM CLI -The stack can be deployed using the [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) and `template.yml`. +The stack can be deployed using the [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/using-sam-cli.html), [Cargo Lambda](https://www.cargo-lambda.info/) to package the code, and `template.yml`. -1. Run the following command replacing `` with the AWS region you want to deploy in and provide parameter overrides as desired: +##### Stack Parameters + +The following parameters are available when deploying the connector as part of a CloudFormation stack. An example of setting these parameters is included in step 4 of the [SAM deployment steps](#sam-deployment-steps). + +| Parameter | Description | Default Value | +|---------------|-------------|---------------| +| `DatabaseName` | The name of the database to use for ingestion. | `influxdb-line-protocol` | +| `LambdaMemorySize` | The size of the memory in MB allocated per invocation of the function. | `128` | +| `LambdaName` | The name to use for the Lambda function. | `influxdb-timestream-connector-lambda` | +| `LambdaTimeoutInSeconds` | The number of seconds to run the Lambda function before timing out. | `30` | +| `MeasureNameForMultiMeasureRecords` | The value to use in records as the `measure_name`, as shown in the [example line protocol to Timestream records translation](#resulting-cpu_load_short-timestream-for-liveanalytics-table). | `influxdb-measure` | +| `RestApiGatewayName` | The name to use for the REST API Gateway. | `InfluxDB-Timestream-Connector-REST-API-Gateway` | +| `RestApiGatewayStageName` | The name to use for the REST API Gateway stage. | `dev` | +| `RestApiGatewayTimeoutInMillis` | The maximum number of milliseconds a REST API Gateway event will wait before timing out. | `30000` | +| `WriteThrottlingBurstLimit` | The number of burst requests per second that the REST API Gateway permits. | `1200` | + +##### SAM Deployment Steps + +1. [Download and install the AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html). +2. [Download and install Rust](https://www.rust-lang.org/tools/install). +3. [Download and install Cargo Lambda](https://www.cargo-lambda.info/guide/installation.html). +4. Run the following command to package the binary in `target/lambda/influxdb-timestream-connector/bootstrap.zip`, where `template.yml` expects it to be, and cross compile for Linux ARM: + ``` + cargo lambda build --release --arm64 --output-format zip + ``` +5. Run the following command, replacing `` with the AWS region you want to deploy in, `` with your desired stack name and providing parameter overrides as desired. Note that this example command uses `--resolve-s3` to automatically create an S3 bucket to use for packaging and deployment and `--capabilities CAPABILITY_IAM` to allow the creation of IAM roles. ```shell - sam --region --parameter-overrides ParameterKey=exampleKey,ParameterValue=exampleValue deploy template.yml + sam deploy template.yml \ + --region \ + --stack-name \ + --resolve-s3 \ + --capabilities CAPABILITY_IAM \ + --parameter-overrides ParameterKey=exampleKey,ParameterValue=exampleValue ``` -2. Once the stack has finished deploying, take note of the output "Endpoint" value. This value will be used as the endpoint for all write requests, for example, `/api/v2/write`. +6. Once the stack has finished deploying, take note of the output `Endpoint` value. This value will be used as the endpoint for all write requests and is analogous to an [InfluxDB host address](https://docs.influxdata.com/influxdb/v2/reference/urls/) and is used in the same way, for example, `/api/v2/write`. + +#### Lambda Dead Letter Queue + +If the connector was deployed with async invocation, then all client requests will be returned a response with a `202` status code, indicating that the request has been received and is being processed. If the request fails, the client will not be notified. Instead, failed requests will either be [logged by the REST API Gateway](#viewing-rest-api-gateway-logs) or be added to the Lambda's dead letter queue, where the failed request can be reviewed in full. The name of the dead letter queue is provided as the `LambdaDeadLetterQueueName` output when deploying the stack. + +To access the Lambda's dead letter queue and view any possible stored messages: + +1. Take note of the dead letter queue's name as provided by the `LambdaDeadLetterQueueName` output upon successful stack deployment. +2. Visit the [Amazon SQS console](https://console.aws.amazon.com/sqs/v3/home). +3. In the navigation pane, choose **Queues**. +4. Find and select the SQS queue with the same name as indicated by `LambdaDeadLetterQueueName`. +5. Choose **Send and receive messages**. +6. Choose **Poll for messages**. #### Stack Logs ##### Viewing REST API Gateway Logs -By default, execution and access logging is enabled for the REST API Gateway. +By default, access logging is enabled for the REST API Gateway. To view logs: 1. Visit the [AWS CloudFormation console](https://console.aws.amazon.com/cloudformation/home). 2. In the navigation pane, choose **Stacks**. 3. Choose your deployed stack from the list of stacks. -4. In the **Resources** tab choose the **Physical ID** of the REST API Gateway. -5. In the navigation pane, under **Monitor**, choose **Logging**. -6. From the dropdown menu, select the currently deployed stage. -7. Choose **View logs in CloudWatch**. +4. In the **Resources** tab choose the **Physical ID** of the `RestApiGatewayLogGroup` resource. ##### Viewing Lambda Logs @@ -89,17 +129,18 @@ To view logs: The connector can be run locally using [Cargo Lambda](https://www.cargo-lambda.info/guide/what-is-cargo-lambda.html). 1. [Download and install Rust](https://www.rust-lang.org/tools/install). -2. [Download and install Cargo Lambda](https://www.cargo-lambda.info/guide/installation.html). -3. Configure the following environment variables: +2. [Configure your AWS credentials for use by the AWS SDK for Rust](https://docs.aws.amazon.com/sdkref/latest/guide/creds-config-files.html). +3. [Download and install Cargo Lambda](https://www.cargo-lambda.info/guide/installation.html). +4. Configure the following environment variables: - `region` string: the AWS region to use. Defaults to `us-east-1`. - `database_name` string: the Timestream for LiveAnalytics database name to use. Defaults to `influxdb-line-protocol`. - `measure_name_for_multi_measure_records` string: the value to use in records as the measure name. Defaults to `influxdb-measure`. -4. To run the connector on `http://localhost:9000` execute the following command: +5. To run the connector on `http://localhost:9000` execute the following command: ```shell cargo lambda watch ``` -5. Send all requests to `http://localhost:9000/api/v2/write`. +6. Send all requests to `http://localhost:9000/api/v2/write`. ## Security @@ -203,7 +244,10 @@ To configure the sample application and ingest all line protocol data contained 5. Run the sample Go client: - With the connector deployed in a CloudFormation stack, replacing `` with the AWS region you deployed your stack in and `` with the endpoint of your deployed REST API Gateway: ```shell - go run line-protocol-client-demo.go --region --service execute-api --endpoint + go run line-protocol-client-demo.go \ + --region \ + --service execute-api \ + --endpoint ``` - With the connector deployed locally: ```shell @@ -229,6 +273,50 @@ To configure the sample application and ingest all line protocol data contained | `ServiceQuotaExceededException` | 400 | The instance quota of resource exceeded for this account. | Confirm you are not exceeding Timestream for LiveAnalytics' quotas. If you are not exceeding any quotas, check the list of [line protocol limitations](#line-protocol-limitations) below, specifically the number of unique measurement names. | | `ThrottlingException` | 400 | Too many requests were made by a user and they exceeded the service quotas. The request was throttled. | Decrease the rate of your requests. | +## Testing + +### Requirements + +1. [Configure your AWS credentials for use by the AWS SDK for Rust](https://docs.aws.amazon.com/sdkref/latest/guide/creds-config-files.html). +2. Ensure your IAM permissions include the permissions listed in the [IAM Execution Permissions](#iam-execution-permissions) section. + +### All Tests + +To run all tests, including integration tests and unit tests, use the following command: + +```shell +cargo test -- --test-threads=1 +``` + +### Integration Tests + +To run all integration tests, use the following command, from the project root: + +```shell +cargo test --test '*' -- --test-threads=1 +``` + +> **NOTE**: It is important to use the flag `--test-threads=1` in order to avoid throttling errors, as the integration tests will create and delete tables. + +To run a specific integration test, use the following command: + +```shell +cargo test +``` + +### Unit Tests + +To run all unit tests, use the following command: + +```shell +cargo test --lib +``` + +To run a single unit test, use the following command: + +```shell +cargo test +``` ## Limitations @@ -250,8 +338,17 @@ Due to the connector translating line protocol to Timestream records, line proto | Latest valid timestamp. | Fifteen minutes in the future from the current time. | | Oldest valid timestamp. | `mag_store_retention_period` days before the current time. | +### Database and Table Creation Delay + +There is a delay of one second added before deleting or creating a table or database. This is because of Timestream for LiveAnalytics' "Throttle rate for CRUD APIs" [quota](https://docs.aws.amazon.com/timestream/latest/developerguide/ts-limits.html#limits.default) of one table/database deletion/creation per second. + ## Caveats ### Line Protocol Tag Requirement In order to ingest to Timestream for LiveAnalytics, every line protocol point must include at least one tag. + +### Query String Parameters + +The connector expects query string parameters to be included as `queryParameters` or `queryStringParameters` in requests. + diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs index 95b85769..25328f43 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs @@ -1,10 +1,10 @@ use anyhow::{anyhow, Error, Result}; use aws_sdk_timestreamwrite as timestream_write; -use lambda_http::{Body, IntoResponse, Request, RequestExt, Response}; +use lambda_runtime::LambdaEvent; use line_protocol_parser::*; use records_builder::*; +use serde_json::{json, Value}; use std::collections::HashMap; -use std::io::prelude::*; use std::{str, thread, time}; use timestream_utils::*; @@ -85,35 +85,143 @@ async fn handle_multi_table_ingestion( Ok(()) } +pub fn get_precision(event: &Value) -> Option<&str> { + // Retrieves the optional "precision" query string parameter from a serde_json::Value + + // Query string parameters may be included as "queryStringParameters" + if let Some(precision) = event.get("queryStringParameters").or_else(|| event.get("queryParameters")) + .and_then(|query_string_parameters| query_string_parameters.get("precision")) { + // event["queryStringParameters"]["precision"] may be an object + if let Some(precision_str) = precision.as_str() { + return Some(precision_str); + // event["queryStringParameters"]["precision"] may be an array. This is common from requests + // originating from AWS services, such as when the connector is ran with the cargo lambda watch command + } else if let Some(precision_array) = precision.as_array() { + if let Some(precision_value) = precision_array.first().and_then(|value| value.as_str()) { + return Some(precision_value); + } + } + } + + None +} + pub async fn lambda_handler( client: ×tream_write::Client, - event: Request, -) -> Result { + event: LambdaEvent, +) -> Result { // Handler for lambda runtime - let precision = match event - .query_string_parameters_ref() - .and_then(|params| params.first("precision")) - { + let (event, _context) = event.into_parts(); + + let precision = match get_precision(&event) { Some("ms") => timestream_write::types::TimeUnit::Milliseconds, Some("us") => timestream_write::types::TimeUnit::Microseconds, Some("s") => timestream_write::types::TimeUnit::Seconds, _ => timestream_write::types::TimeUnit::Nanoseconds, }; - let data: Result, _> = event.body().bytes().collect(); - let data = data?; - - match handle_body(client, &data, &precision).await { - Ok(_) => Ok(Response::builder() - .status(200) - .header("content-type", "text/html") - .body(Body::Empty) - .map_err(Box::new)?), - Err(error) => Ok(Response::builder() - .status(400) - .header("content-type", "text/html") - .body(Body::Text(error.to_string())) - .map_err(Box::new)?), + let data = event + .get("body") + .expect("No body was included in the request") + .as_str() + .expect("Failed to convert body to &str") + .as_bytes(); + + match handle_body(client, data, &precision).await { + // This is the format required for custom Lambda responses + // https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html + Ok(_) => Ok(json!({ + "statusCode": 200, + "body": "{\"message\": \"Success\"}", + "isBase64Encoded": false, + "headers": { + "Content-Type": "application/json" + }, + "cookies": [] + })), + // An Err is required in order to send messages to the Lambda's + // dead letter queue, when the connector is deployed as part of a stack + // with asynchronous invocation + Err(error) => Err(anyhow!(error.to_string())), } } + +#[cfg(test)] + +#[test] +pub fn test_get_precision_query_string_parameters_array() -> Result<(), Error> { + let fake_event_value = json!({ "queryStringParameters": { "precision": ["ms"] } }); + let precision = get_precision(&fake_event_value); + assert!(precision.is_some()); + assert!(precision.expect("Failed to get precision") == "ms"); + Ok(()) +} + +#[test] +pub fn test_get_precision_query_string_parameters_object() -> Result<(), Error> { + let fake_event_value = json!({ "queryStringParameters": { "precision": "ms" } }); + let precision = get_precision(&fake_event_value); + assert!(precision.is_some()); + assert!(precision.expect("Failed to get precision") == "ms"); + Ok(()) +} + +#[test] +pub fn test_get_precision_query_string_parameters_object_nanoseconds() -> Result<(), Error> { + let fake_event_value = json!({ "queryStringParameters": { "precision": "ns" } }); + let precision = get_precision(&fake_event_value); + assert!(precision.is_some()); + assert!(precision.expect("Failed to get precision") == "ns"); + Ok(()) +} + +#[test] +pub fn test_get_precision_query_string_parameters_object_microseconds() -> Result<(), Error> { + let fake_event_value = json!({ "queryStringParameters": { "precision": "us" } }); + let precision = get_precision(&fake_event_value); + assert!(precision.is_some()); + assert!(precision.expect("Failed to get precision") == "us"); + Ok(()) +} + +#[test] +pub fn test_get_precision_query_string_parameters_object_seconds() -> Result<(), Error> { + let fake_event_value = json!({ "queryStringParameters": { "precision": "s" } }); + let precision = get_precision(&fake_event_value); + assert!(precision.is_some()); + assert!(precision.expect("Failed to get precision") == "s"); + Ok(()) +} + +#[test] +pub fn test_get_precision_query_parameters_array() -> Result<(), Error> { + let fake_event_value = json!({ "queryParameters": { "precision": ["ms"] } }); + let precision = get_precision(&fake_event_value); + assert!(precision.is_some()); + assert!(precision.expect("Failed to get precision") == "ms"); + Ok(()) +} + +#[test] +pub fn test_get_precision_query_parameters_object() -> Result<(), Error> { + let fake_event_value = json!({ "queryParameters": { "precision": "ms" } }); + let precision = get_precision(&fake_event_value); + assert!(precision.is_some()); + assert!(precision.expect("Failed to get precision") == "ms"); + Ok(()) +} + +#[test] +pub fn test_get_precision_incorrect_query_parameters_key() -> Result<(), Error> { + let fake_event_value = json!({ "nomatch": { "precision": "ms" } }); + assert!(get_precision(&fake_event_value).is_none()); + Ok(()) +} + +#[test] +pub fn test_get_precision_incorrect_precision_key() -> Result<(), Error> { + let fake_event_value = json!({ "queryStringParameters": { "nomatch": "ms" } }); + assert!(get_precision(&fake_event_value).is_none()); + Ok(()) +} diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/main.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/main.rs index aace484a..56b7818d 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/main.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/main.rs @@ -1,15 +1,16 @@ use influxdb_timestream_connector::{ lambda_handler, records_builder::validate_env_variables, timestream_utils::get_connection, }; -use lambda_http::{run, service_fn, tracing, Error as lambda_error, Request}; +use lambda_runtime::{run, service_fn, tracing, Error, LambdaEvent}; +use serde_json::Value; #[tokio::main] -async fn main() -> Result<(), lambda_error> { +async fn main() -> Result<(), Error> { validate_env_variables()?; let region = std::env::var("region")?; let timestream_client = get_connection(®ion).await?; tracing::init_default_subscriber(); - run(service_fn(|event: Request| { + run(service_fn(|event: LambdaEvent| { lambda_handler(×tream_client, event) })) .await diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml new file mode 100644 index 00000000..a4ddbc5c --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml @@ -0,0 +1,527 @@ +Transform: AWS::Serverless-2016-10-31 + +Metadata: + AWS::ServerlessRepo::Application: + Name: influxdb-timestream-connector + Description: This serverless application deployes an AWS Lambda function and an Amazon REST API Gateway that listens for POST requests with line protocol bodies. The line protocol data will be translated and stored in Timestream for LiveAnalytics. This application allows configuration over the resources it creates. + Author: Amazon Timestream + SpdxLicenseId: MIT-0 + LicenseUrl: ../../../LICENSE + ReadmeUrl: ./README.md + HomePageUrl: https://aws.amazon.com/timestream/ + SemanticVersion: 1.0.0 + +Parameters: + DatabaseName: + Type: String + Default: influxdb-line-protocol + Description: The Timestream for LiveAnalytics database name to use. + MeasureNameForMultiMeasureRecords: + Type: String + Default: influxdb-measure + Description: The value to use in records as the measure name. + EnableAsyncInvocation: + Type: String + Default: 'true' + AllowedValues: + - 'true' + - 'false' + EnableDatabaseCreation: + Type: String + AllowedValues: + - 'true' + - 'false' + Default: 'true' + EnableTableCreation: + Type: String + AllowedValues: + - 'true' + - 'false' + Default: 'true' + EnableMagStoreWrites: + Type: String + AllowedValues: + - 'true' + - 'false' + Default: 'true' + MagStoreRetentionPeriod: + Type: Number + Default: 8000 + MemStoreRetentionPeriod: + Type: Number + Default: 12 + RestApiGatewayName: + Type: String + Default: InfluxDB-Timestream-Connector-REST-API-Gateway + RestApiGatewayStageName: + Type: String + Default: dev + Description: The default stage name of the REST API Gateway. + LambdaMemorySize: + Type: Number + Default: 128 + MinValue: 128 + MaxValue: 8192 + Description: The memory size of the Lambda function. + RestApiGatewayTimeoutInMillis: + Type: Number + MinValue: 2 + Default: 30000 + Description: The maximum amount of time in milliseconds an API Gateway event will wait before timing out. + LambdaTimeoutInSeconds: + Type: Number + MinValue: 3 + Default: 30 + Description: The amount of time in seconds to run the connector on AWS Lambda before timing out. + WriteThrottlingBurstLimit: + Type: Number + Default: 1200 + Description: The number of burst requests per second that the REST API Gateway permits. + +Conditions: + IsAsyncEnabled: !Equals [ !Ref EnableAsyncInvocation, "true" ] + IsSyncEnabled: !Equals [ !Ref EnableAsyncInvocation, "false" ] + +Resources: + RestApiGateway: + Type: AWS::ApiGateway::RestApi + Properties: + Name: !Sub "${RestApiGatewayName}-${AWS::StackName}" + + ApiResource: + Type: AWS::ApiGateway::Resource + Properties: + ParentId: !GetAtt RestApiGateway.RootResourceId + PathPart: api + RestApiId: !Ref RestApiGateway + + V2Resource: + Type: AWS::ApiGateway::Resource + Properties: + ParentId: !Ref ApiResource + PathPart: v2 + RestApiId: !Ref RestApiGateway + + WriteResource: + Type: AWS::ApiGateway::Resource + Properties: + ParentId: !Ref V2Resource + PathPart: write + RestApiId: !Ref RestApiGateway + + SyncRestApiGatewayPostMethod: + Type: AWS::ApiGateway::Method + Condition: IsSyncEnabled + Properties: + ResourceId: !Ref WriteResource + RestApiId: !Ref RestApiGateway + AuthorizationType: AWS_IAM + HttpMethod: POST + ApiKeyRequired: false + Integration: + IntegrationHttpMethod: POST + Type: AWS_PROXY + Uri: !Sub + - arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaArn}/invocations + - LambdaArn: !GetAtt LambdaFunction.Arn + IntegrationResponses: + - StatusCode: '200' + SelectionPattern: '2\d{2}' + ResponseTemplates: + application/json: | + { + "statusCode": 200, + "body": "$input.body" + } + - StatusCode: '500' + SelectionPattern: '5\d{2}' + ResponseTemplates: + application/json: | + { + "statusCode": "Internal error", + "error": "$util.escapeJavaScript($input.path('$.errorMessage'))" + } + MethodResponses: + - StatusCode: '200' # Synchronous response, success + - StatusCode: '500' + + AsyncRestApiGatewayPostMethod: + Type: AWS::ApiGateway::Method + Condition: IsAsyncEnabled + Properties: + ResourceId: !Ref WriteResource + RestApiId: !Ref RestApiGateway + AuthorizationType: AWS_IAM + HttpMethod: POST + ApiKeyRequired: false + Integration: + IntegrationHttpMethod: POST + Type: AWS + PassthroughBehavior: WHEN_NO_MATCH + RequestTemplates: + text/plain: | + { + "path": "$context.path", + "httpMethod": "$context.httpMethod", + "body" : "$util.escapeJavaScript($input.body)", + "queryStringParameters": { + #foreach($param in $input.params().querystring.keySet()) + "$param": "$util.escapeJavaScript($input.params().querystring.get($param))" #if($foreach.hasNext),#end + #end + }, + "requestContext": { + "accountId": "$context.identity.accountId", + "resourceId": "$context.resourceId", + "stage": "$context.stage", + "requestId": "$context.requestId", + "requestTimeEpoch": "$context.requestTimeEpoch", + "identity": { + "cognitoIdentityPoolId": "$context.identity.cognitoIdentityPoolId", + "accountId": "$context.identity.accountId", + "cognitoIdentityId": "$context.identity.cognitoIdentityId", + "caller": "$context.identity.caller", + "apiKey": "$context.apiKey", + "sourceIp": "$context.identity.sourceIp", + "cognitoAuthenticationType": "$context.identity.cognitoAuthenticationType", + "cognitoAuthenticationProvider": "$context.identity.cognitoAuthenticationProvider", + "userArn": "$context.identity.userArn", + "userAgent": "$context.identity.userAgent", + "user": "$context.identity.user" + }, + "resourcePath": "$context.resourcePath", + "httpMethod": "$context.httpMethod", + "apiId": "$context.apiId" + }, + "isBase64Encoded": false + } + text/plain; charset=utf-8: | + { + "path": "$context.path", + "httpMethod": "$context.httpMethod", + "body" : "$util.escapeJavaScript($input.body)", + "queryStringParameters": { + #foreach($param in $input.params().querystring.keySet()) + "$param": "$util.escapeJavaScript($input.params().querystring.get($param))" #if($foreach.hasNext),#end + #end + }, + "requestContext": { + "accountId": "$context.identity.accountId", + "resourceId": "$context.resourceId", + "stage": "$context.stage", + "requestId": "$context.requestId", + "requestTimeEpoch": "$context.requestTimeEpoch", + "identity": { + "cognitoIdentityPoolId": "$context.identity.cognitoIdentityPoolId", + "accountId": "$context.identity.accountId", + "cognitoIdentityId": "$context.identity.cognitoIdentityId", + "caller": "$context.identity.caller", + "apiKey": "$context.apiKey", + "sourceIp": "$context.identity.sourceIp", + "cognitoAuthenticationType": "$context.identity.cognitoAuthenticationType", + "cognitoAuthenticationProvider": "$context.identity.cognitoAuthenticationProvider", + "userArn": "$context.identity.userArn", + "userAgent": "$context.identity.userAgent", + "user": "$context.identity.user" + }, + "resourcePath": "$context.resourcePath", + "httpMethod": "$context.httpMethod", + "apiId": "$context.apiId" + }, + "isBase64Encoded": false + } + text/csv: | + { + "path": "$context.path", + "httpMethod": "$context.httpMethod", + "body" : "$util.escapeJavaScript($input.body)", + "queryStringParameters": { + #foreach($param in $input.params().querystring.keySet()) + "$param": "$util.escapeJavaScript($input.params().querystring.get($param))" #if($foreach.hasNext),#end + #end + }, + "requestContext": { + "accountId": "$context.identity.accountId", + "resourceId": "$context.resourceId", + "stage": "$context.stage", + "requestId": "$context.requestId", + "requestTimeEpoch": "$context.requestTimeEpoch", + "identity": { + "cognitoIdentityPoolId": "$context.identity.cognitoIdentityPoolId", + "accountId": "$context.identity.accountId", + "cognitoIdentityId": "$context.identity.cognitoIdentityId", + "caller": "$context.identity.caller", + "apiKey": "$context.apiKey", + "sourceIp": "$context.identity.sourceIp", + "cognitoAuthenticationType": "$context.identity.cognitoAuthenticationType", + "cognitoAuthenticationProvider": "$context.identity.cognitoAuthenticationProvider", + "userArn": "$context.identity.userArn", + "userAgent": "$context.identity.userAgent", + "user": "$context.identity.user" + }, + "resourcePath": "$context.resourcePath", + "httpMethod": "$context.httpMethod", + "apiId": "$context.apiId" + }, + "isBase64Encoded": false + } + application/json: | + { + "path": "$context.path", + "httpMethod": "$context.httpMethod", + "body" : "$util.escapeJavaScript($input.body)", + "queryStringParameters": { + #foreach($param in $input.params().querystring.keySet()) + "$param": "$util.escapeJavaScript($input.params().querystring.get($param))" #if($foreach.hasNext),#end + #end + }, + "requestContext": { + "accountId": "$context.identity.accountId", + "resourceId": "$context.resourceId", + "stage": "$context.stage", + "requestId": "$context.requestId", + "requestTimeEpoch": "$context.requestTimeEpoch", + "identity": { + "cognitoIdentityPoolId": "$context.identity.cognitoIdentityPoolId", + "accountId": "$context.identity.accountId", + "cognitoIdentityId": "$context.identity.cognitoIdentityId", + "caller": "$context.identity.caller", + "apiKey": "$context.apiKey", + "sourceIp": "$context.identity.sourceIp", + "cognitoAuthenticationType": "$context.identity.cognitoAuthenticationType", + "cognitoAuthenticationProvider": "$context.identity.cognitoAuthenticationProvider", + "userArn": "$context.identity.userArn", + "userAgent": "$context.identity.userAgent", + "user": "$context.identity.user" + }, + "resourcePath": "$context.resourcePath", + "httpMethod": "$context.httpMethod", + "apiId": "$context.apiId" + }, + "isBase64Encoded": false + } + Uri: !Sub + - arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaArn}/invocations + - LambdaArn: !GetAtt LambdaFunction.Arn + RequestParameters: + integration.request.header.X-Amz-Invocation-Type: "'Event'" + IntegrationResponses: + - StatusCode: '202' + MethodResponses: + - StatusCode: '202' # Asynchronous response, accepted but not yet processed + + AsyncRestApiGatewayDeployment: + Type: AWS::ApiGateway::Deployment + Condition: IsAsyncEnabled + DependsOn: AsyncRestApiGatewayPostMethod + Properties: + RestApiId: !Ref RestApiGateway + + SyncRestApiGatewayDeployment: + Type: AWS::ApiGateway::Deployment + Condition: IsSyncEnabled + DependsOn: SyncRestApiGatewayPostMethod + Properties: + RestApiId: !Ref RestApiGateway + + AsyncRestApiGatewayStage: + Type: AWS::ApiGateway::Stage + Condition: IsAsyncEnabled + DependsOn: AsyncRestApiGatewayDeployment + Properties: + StageName: !Ref RestApiGatewayStageName + RestApiId: !Ref RestApiGateway + DeploymentId: !Ref AsyncRestApiGatewayDeployment + MethodSettings: + - DataTraceEnabled: true + HttpMethod: "*" + ResourcePath: "/*" + LoggingLevel: INFO + MetricsEnabled: true + AccessLogSetting: + DestinationArn: !GetAtt RestApiGatewayLogGroup.Arn + Format: | + {"requestId":"$context.requestId","ip":"$context.identity.sourceIp","caller":"$context.identity.caller","user":"$context.identity.user","requestTime":"$context.requestTime","httpMethod":"$context.httpMethod","resourcePath":"$context.resourcePath","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength","errorMessage":"$context.error.message"} + TracingEnabled: true + Variables: + lambdaAlias: !Ref RestApiGatewayStageName + + SyncRestApiGatewayStage: + Type: AWS::ApiGateway::Stage + Condition: IsSyncEnabled + DependsOn: SyncRestApiGatewayDeployment + Properties: + StageName: !Ref RestApiGatewayStageName + RestApiId: !Ref RestApiGateway + DeploymentId: !Ref SyncRestApiGatewayDeployment + MethodSettings: + - DataTraceEnabled: true + HttpMethod: "*" + ResourcePath: "/*" + LoggingLevel: INFO + MetricsEnabled: true + AccessLogSetting: + DestinationArn: !GetAtt RestApiGatewayLogGroup.Arn + Format: | + {"requestId":"$context.requestId","ip":"$context.identity.sourceIp","caller":"$context.identity.caller","user":"$context.identity.user","requestTime":"$context.requestTime","httpMethod":"$context.httpMethod","resourcePath":"$context.resourcePath","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength","errorMessage":"$context.error.message"} + TracingEnabled: true + Variables: + lambdaAlias: !Ref RestApiGatewayStageName + + AsyncRestApiGatewayUsagePlan: + Type: AWS::ApiGateway::UsagePlan + Condition: IsAsyncEnabled + DependsOn: AsyncRestApiGatewayStage + Properties: + ApiStages: + - ApiId: !Ref RestApiGateway + Stage: !Ref RestApiGatewayStageName + Throttle: + BurstLimit: !Ref WriteThrottlingBurstLimit + + SyncRestApiGatewayUsagePlan: + Type: AWS::ApiGateway::UsagePlan + Condition: IsSyncEnabled + DependsOn: SyncRestApiGatewayStage + Properties: + ApiStages: + - ApiId: !Ref RestApiGateway + Stage: !Ref RestApiGatewayStageName + Throttle: + BurstLimit: !Ref WriteThrottlingBurstLimit + + RestApiGatewayLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/apigateway/${RestApiGateway.RestApiId}" + RetentionInDays: 14 + + RestApiGatewayLogsRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: apigateway.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" + + RestApiGatewayLogAccount: + Type: AWS::ApiGateway::Account + Properties: + CloudWatchRoleArn: !GetAtt RestApiGatewayLogsRole.Arn + + LambdaDeadLetterQueue: + Type: AWS::SQS::Queue + + LambdaExecutionRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - sts:AssumeRole + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Policies: + - PolicyName: LambdaLogPolicy + PolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Effect: Allow + Resource: + - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/* + - PolicyName: LambdaSQSDlqPolicy + PolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - sqs:SendMessage + Effect: Allow + Resource: !GetAtt LambdaDeadLetterQueue.Arn + - PolicyName: LambdaTimestreamDescribeEndpointsPolicy + PolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - timestream:DescribeEndpoints + Effect: Allow + Resource: "*" + - PolicyName: LambdaTimestreamWritePolicy + PolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - timestream:DescribeTable + - timestream:WriteRecords + - timestream:CreateTable + Effect: Allow + Resource: !Sub arn:aws:timestream:${AWS::Region}:${AWS::AccountId}:database/${DatabaseName}/table/* + - PolicyName: LambdaTimestreamDatabasePolicy + PolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - timestream:DescribeDatabase + - timestream:CreateDatabase + Effect: Allow + Resource: !Sub arn:aws:timestream:${AWS::Region}:${AWS::AccountId}:database/${DatabaseName} + + LambdaFunction: + Type: AWS::Serverless::Function + Properties: + Role: !GetAtt LambdaExecutionRole.Arn + Runtime: provided.al2023 + Handler: influxdb-timestream-connector + Architectures: + - arm64 + CodeUri: ./target/lambda/influxdb-timestream-connector/bootstrap.zip + DeadLetterQueue: + Type: SQS + TargetArn: !GetAtt LambdaDeadLetterQueue.Arn + Events: + RestApiGatewayEvent: + Type: Api + Properties: + RestApiId: !Ref RestApiGateway + Path: /api/v2/write + Method: POST + EventInvokeConfig: + DestinationConfig: + OnFailure: + Type: SQS + Destination: !GetAtt LambdaDeadLetterQueue.Arn + MaximumEventAgeInSeconds: 3600 + MaximumRetryAttempts: 0 + Environment: + Variables: + database_name: !Ref DatabaseName + measure_name_for_multi_measure_records: !Ref MeasureNameForMultiMeasureRecords + region: !Ref AWS::Region + enable_database_creation: !Ref EnableDatabaseCreation + enable_table_creation: !Ref EnableTableCreation + enable_mag_store_writes: !Ref EnableMagStoreWrites + mag_store_retention_period: !Ref MagStoreRetentionPeriod + mem_store_retention_period: !Ref MemStoreRetentionPeriod + PackageType: Zip + Timeout: !Ref LambdaTimeoutInSeconds + MemorySize: !Ref LambdaMemorySize + +Outputs: + Endpoint: + Description: The endpoint for the REST API Gateway. + Value: !Sub https://${RestApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${RestApiGatewayStageName} + LambdaDeadLetterQueueName: + Description: The name of the dead letter queue used by the Lambda function. + Condition: IsAsyncEnabled + Value: !GetAtt LambdaDeadLetterQueue.QueueName + diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs b/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs index 656e1390..ad6867ea 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs @@ -2,10 +2,9 @@ use anyhow::Error; use aws_credential_types::Credentials; use aws_sdk_timestreamwrite as timestream_write; use aws_types::region::Region; -use lambda_http::http::StatusCode; -use lambda_http::{http::Request, IntoResponse}; -use lambda_http::{Body, RequestExt}; +use lambda_runtime::{Context, LambdaEvent}; use rand::{distributions::uniform::SampleUniform, distributions::Alphanumeric, Rng}; +use serde_json::{json, Value}; use std::collections::HashMap; use std::{env, thread, time}; @@ -15,25 +14,22 @@ static MAX_TIMESTREAM_TABLE_NAME_LENGTH: usize = 256; // A batch of resources to be deleted at the end of a test. struct CleanupBatch { - client: timestream_write::Client, database_name: String, table_names_to_delete: Vec, } impl CleanupBatch { pub fn new( - client: timestream_write::Client, database_name: String, table_names_to_delete: Vec, ) -> CleanupBatch { CleanupBatch { - client, database_name, table_names_to_delete, } } - async fn cleanup(&mut self) { + async fn cleanup(&mut self, client: ×tream_write::Client) { for table_name_to_delete in self.table_names_to_delete.iter() { println!( "Deleting table {} in database {}", @@ -42,8 +38,7 @@ impl CleanupBatch { thread::sleep(time::Duration::from_secs( influxdb_timestream_connector::TIMESTREAM_API_WAIT_SECONDS, )); - let result = self - .client + let result = client .delete_table() .database_name(&self.database_name) .table_name(table_name_to_delete) @@ -116,20 +111,130 @@ async fn test_mtmm_basic() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() + let mut cleanup_batch = + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_create_database() -> Result<(), Error> { + // Tests ingesting a single point and creating a database. + set_environment_variables(); + let test_create_database_name = "test_create_database_influxdb_timestream_connector_integ"; + env::set_var("database_name", test_create_database_name); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response: {:?}", response); + + let mut cleanup_batch = + CleanupBatch::new(test_create_database_name.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; + let database_delete_response = client + .delete_database() + .database_name(test_create_database_name) + .send() .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + if database_delete_response.is_err() { + println!("The database {} failed to delete", test_create_database_name); + } + + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_unusual_query_parameters() -> Result<(), Error> { + // Tests ingesting a single point with a query parameters key with unusual spelling. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = LambdaEvent::::new( + json!({ "QUERYtestStrparaMETERS": query_parameters, "body": point }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; + + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_no_query_parameters() -> Result<(), Error> { + // Tests ingesting a single point without query parameters. + set_environment_variables(); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let request = LambdaEvent::::new(json!({ "body": point }), Context::default()); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); + + let mut cleanup_batch = + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -137,6 +242,8 @@ async fn test_mtmm_basic() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_multiple_timestamps() -> Result<(), Error> { // Tests ingesting a single point with two timestamps. + // Note, the connector either returns JSON with a 200 status code or an Error. This is so that + // the dead letter queue works when the connector is deployed as part of a stack. set_environment_variables(); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await @@ -154,16 +261,17 @@ async fn test_mtmm_multiple_timestamps() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() != StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + assert!(response.is_err()); + + let mut cleanup_batch = + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -196,23 +304,21 @@ async fn test_mtmm_many_tags_many_fields() -> Result<(), Error> { point.push_str(&format!(" {}\n", chrono::Utc::now().timestamp_millis())); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -236,20 +342,18 @@ async fn test_mtmm_float() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -273,20 +377,18 @@ async fn test_mtmm_string() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -310,20 +412,18 @@ async fn test_mtmm_bool() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -351,20 +451,18 @@ async fn test_mtmm_max_tag_length() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -392,20 +490,17 @@ async fn test_mtmm_beyond_max_tag_length() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() != StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + assert!(response.is_err()); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -432,20 +527,18 @@ async fn test_mtmm_max_field_length() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -472,20 +565,17 @@ async fn test_mtmm_beyond_max_field_length() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() != StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + assert!(response.is_err()); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -516,20 +606,18 @@ async fn test_mtmm_max_unique_field_keys() -> Result<(), Error> { } let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(lp_batch))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": lp_batch }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -560,20 +648,18 @@ async fn test_mtmm_beyond_max_unique_field_keys() -> Result<(), Error> { } let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(lp_batch))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": lp_batch }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() != StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response: {:?}", response); + assert!(response.is_err()); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -604,20 +690,18 @@ async fn test_mtmm_max_unique_tag_keys() -> Result<(), Error> { } let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(lp_batch))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": lp_batch }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -648,20 +732,18 @@ async fn test_mtmm_beyond_max_unique_tag_keys() -> Result<(), Error> { } let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(lp_batch))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": lp_batch }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() != StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response: {:?}", response); + assert!(response.is_err()); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -687,26 +769,24 @@ async fn test_mtmm_max_table_name_length() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } #[tokio::test] -async fn test_mtmm_beyond_max_table_name_length() { +async fn test_mtmm_beyond_max_table_name_length() -> Result<(), Error> { // Tests ingesting a single point with measurement with length // exceeding the maximum number of bytes a Timestream table name can // have. @@ -726,19 +806,19 @@ async fn test_mtmm_beyond_max_table_name_length() { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point)) - .expect("Failed to create request") - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await - .expect("Failed to send request to lambda handler") - .into_response() - .await; + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + assert!(response.is_err()); + + let mut cleanup_batch = + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() != StatusCode::OK); + Ok(()) } #[tokio::test] @@ -762,20 +842,18 @@ async fn test_mtmm_nanosecond_precision() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "ns".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -799,20 +877,18 @@ async fn test_mtmm_microsecond_precision() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "us".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -836,20 +912,18 @@ async fn test_mtmm_second_precision() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "s".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -876,21 +950,19 @@ async fn test_mtmm_no_precision() -> Result<(), Error> { .expect("Failed to create nanosecond timestamp") ); - let request = Request::builder() - .method("POST") - .body(Body::Text(point))?; + let request = LambdaEvent::::new( + json!({ "queryStringParameters": "", "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -905,17 +977,15 @@ async fn test_mtmm_empty_point() -> Result<(), Error> { let point = String::new(); - let request = Request::builder() - .method("POST") - .body(Body::Text(point))?; + let request = LambdaEvent::::new( + json!({ "queryStringParameters": "", "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); Ok(()) } @@ -939,20 +1009,18 @@ pub async fn test_mtmm_small_timestamp() -> Result<(), Error> { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -984,20 +1052,18 @@ async fn test_mtmm_5_measurements() -> Result<(), Error> { } let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(lp_batch))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": lp_batch }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), table_names_to_delete); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), table_names_to_delete); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -1029,20 +1095,18 @@ async fn test_mtmm_100_measurements() -> Result<(), Error> { } let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(lp_batch))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": lp_batch }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), table_names_to_delete); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), table_names_to_delete); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -1072,20 +1136,18 @@ async fn test_mtmm_5000_batch() -> Result<(), Error> { } let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(lp_batch))? - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": lp_batch }), + Context::default(), + ); - let response = influxdb_timestream_connector::lambda_handler(&client, request) - .await? - .into_response() - .await; - println!("Response: {}: {:?}", response.status(), response.body()); - assert!(response.status() == StatusCode::OK); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; + println!("Response: {}: {:?}", response["statusCode"], response["body"]); + assert!(response["statusCode"] == 200); let mut cleanup_batch = - CleanupBatch::new(client, DATABASE_NAME.to_string(), vec![lp_measurement_name]); - cleanup_batch.cleanup().await; + CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; Ok(()) } @@ -1119,12 +1181,12 @@ async fn test_mtmm_no_credentials() { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point)) - .expect("Failed to created request") - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let _response = influxdb_timestream_connector::lambda_handler(&client, request).await; + let _ = influxdb_timestream_connector::lambda_handler(&client, request).await; } #[tokio::test] @@ -1162,10 +1224,10 @@ async fn test_mtmm_incorrect_credentials() { ); let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); - let request = Request::builder() - .body(Body::Text(point)) - .expect("Failed to created request") - .with_query_string_parameters(query_parameters); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); - let _response = influxdb_timestream_connector::lambda_handler(&client, request).await; + let _ = influxdb_timestream_connector::lambda_handler(&client, request).await; } From 8c8bc2feb32eaf9c88d4fbd0a592e0b7b30d0ea1 Mon Sep 17 00:00:00 2001 From: Trevor Bonas <45324987+trevorbonas@users.noreply.github.com> Date: Fri, 11 Oct 2024 10:39:08 -0700 Subject: [PATCH 04/11] Add support for customer-defined partition keys - Users can now define partition keys for their newly-created tables. Partition key configuration is controlled using three environment variables, `custom_partition_key_type`, `custom_partition_key_dimension`, and `enforce_custom_partition_key`. - Environment variables added for CDPK support. - SAM template parameters added for CDPK support. - Documentation added for CDPK support. - Integration tests added for CDPK support. - Integration test asserts moved to end of tests, in order to ensure resources are cleaned up. - [x] Unit tests passed. - [x] Integration tests passed. --- .../influxdb-timestream-connector/README.md | 59 ++ .../influxdb-timestream-connector/src/lib.rs | 27 +- .../src/records_builder.rs | 93 +++ .../src/timestream_utils.rs | 18 + .../template.yml | 40 +- .../tests/integration_test.rs | 570 ++++++++++++++---- 6 files changed, 661 insertions(+), 146 deletions(-) diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/README.md b/integrations/influxdb_connector/influxdb-timestream-connector/README.md index 30b3bb3f..f43a01e7 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/README.md +++ b/integrations/influxdb_connector/influxdb-timestream-connector/README.md @@ -55,10 +55,18 @@ The following parameters are available when deploying the connector as part of a | Parameter | Description | Default Value | |---------------|-------------|---------------| +| `CustomPartitionKeyDimension` | The dimension to use as the partition key. This parameter is required if the CustomPartitionKeyType parameter is set to `dimension`. | | +| `CustomPartitionKeyType` | The type of custom partition key to use. Valid options are `dimension` or `measure`. The `dimension` option requires the CustomPartitionKeyDimension parameter to also be set. If this parameter is not provided, newly-created tables will use default partitioning and none of the parameters relating to custom partition keys will be used. | | | `DatabaseName` | The name of the database to use for ingestion. | `influxdb-line-protocol` | +| `EnableDatabaseCreation` | Whether to allow database creation upon ingestion of records. | `true` | +| `EnableTableCreation` | Whether to allow table creation upon ingestion of records. When using multi-table multi measure schema, each unique line protocol measurement in a request will result in the creation of a new table with the same name as the measurement. | `true` | +| `EnableMagStoreWrites` | if `EnableTableCreation` is `true`, whether to enable mag store writes. | `true` | +| `EnforceCustomPartitionKey` | Whether to only allow the ingestion of records that contain the custom partition key. Valid options are `true` or `false`. | | | `LambdaMemorySize` | The size of the memory in MB allocated per invocation of the function. | `128` | | `LambdaName` | The name to use for the Lambda function. | `influxdb-timestream-connector-lambda` | | `LambdaTimeoutInSeconds` | The number of seconds to run the Lambda function before timing out. | `30` | +| `MagStoreRetentionPeriod` | If `EnableTableCreation` is `true`, the number of days in which data must be stored in the magnetic store. | `8000` | +| `MemStoreRetentionPeriod` | If `EnableTableCreation` is `true`, the number of hours in which data must be stored in the memory store. | `12` | | `MeasureNameForMultiMeasureRecords` | The value to use in records as the `measure_name`, as shown in the [example line protocol to Timestream records translation](#resulting-cpu_load_short-timestream-for-liveanalytics-table). | `influxdb-measure` | | `RestApiGatewayName` | The name to use for the REST API Gateway. | `InfluxDB-Timestream-Connector-REST-API-Gateway` | | `RestApiGatewayStageName` | The name to use for the REST API Gateway stage. | `dev` | @@ -135,6 +143,11 @@ The connector can be run locally using [Cargo Lambda](https://www.cargo-lambda.i - `region` string: the AWS region to use. Defaults to `us-east-1`. - `database_name` string: the Timestream for LiveAnalytics database name to use. Defaults to `influxdb-line-protocol`. - `measure_name_for_multi_measure_records` string: the value to use in records as the measure name. Defaults to `influxdb-measure`. + - `enable_database_creation` bool: whether to create a database if the `database_name` database does not already exist in Timestream for LiveAnalytics. Defaults to `true`. + - `enable_table_creation` bool: whether to create new tables if they don't already exist. Defaults to `true`. + - `enable_mag_store_writes` bool: if `enable_table_creation` is `true`, whether to enable mag store writes. Defaults to `true`. + - `mag_store_retention_period` int: if `enable_table_creation` is `true`, the number of days in which data must be stored in the magnetic store. Defaults to `8000`. + - `mem_store_retention_period` int: if `enable_table_creation` is `true`, the number of hours in which data must be stored in the memory store. Defaults to `12`. 5. To run the connector on `http://localhost:9000` execute the following command: ```shell @@ -142,6 +155,52 @@ The connector can be run locally using [Cargo Lambda](https://www.cargo-lambda.i ``` 6. Send all requests to `http://localhost:9000/api/v2/write`. +## Custom Partition Keys + +When the environment variable `enable_table_creation` is `true` and records are ingested using multi-table multi measure ingestion, [custom partition keys](https://aws.amazon.com/blogs/database/introducing-customer-defined-partition-keys-for-amazon-timestream-optimizing-query-performance/) can be defined for the newly-created tables. + +To define a custom partition key, set the environment variable `custom_partition_key_type` to either `dimension` or `measure`. + +When `custom_partition_key_type` is set to `measure`, the measure will be used to partition the table. No additional environment variables are necessary. + +When `custom_partition_key_type` is set to `dimension`, the environment variables `custom_partition_key_dimension` and `enforce_custom_partition_key` must also be defined. `custom_partition_key_dimension` specifies the dimension in which you want to use to partition your table while `enforce_custom_partition_key` determines whether all ingested records **must** contain the custom partition key. + +> **NOTE**: Once a partition key has been configured for a table, it cannot be changed or removed. + +### Custom Partition Key Examples + +#### Local Environment + +The following example shows how a custom partition key can be configured for a local environment: + +```shell +# Environment variable values are case-sensitive +export custom_partition_key_type=dimension; + +# One of the tag keys in the example bird migration dataset +export custom_partition_key_dimension=id; +export enforce_custom_partition_key=false; + +# Run the connector locally +cargo lambda watch; +``` + +#### Deployed Environment + +The following example shows how a custom partition key can be configured when deploying the connector as part of a CloudFormation stack: + +```shell +sam deploy template.yml \ + --region \ + --stack-name \ + --resolve-s3 \ + --capabilities CAPABILITY_IAM \ + --parameter-overrides \ + CustomPartitionKeyType=dimension \ + CustomPartitionKeyDimension=id \ + EnforceCustomPartitionKey=false +``` + ## Security ### Encryption diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs index 25328f43..ff5d6d1a 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs @@ -89,18 +89,22 @@ pub fn get_precision(event: &Value) -> Option<&str> { // Retrieves the optional "precision" query string parameter from a serde_json::Value // Query string parameters may be included as "queryStringParameters" - if let Some(precision) = event.get("queryStringParameters").or_else(|| event.get("queryParameters")) - .and_then(|query_string_parameters| query_string_parameters.get("precision")) { - // event["queryStringParameters"]["precision"] may be an object - if let Some(precision_str) = precision.as_str() { - return Some(precision_str); - // event["queryStringParameters"]["precision"] may be an array. This is common from requests - // originating from AWS services, such as when the connector is ran with the cargo lambda watch command - } else if let Some(precision_array) = precision.as_array() { - if let Some(precision_value) = precision_array.first().and_then(|value| value.as_str()) { - return Some(precision_value); - } + if let Some(precision) = event + .get("queryStringParameters") + .or_else(|| event.get("queryParameters")) + .and_then(|query_string_parameters| query_string_parameters.get("precision")) + { + // event["queryStringParameters"]["precision"] may be an object + if let Some(precision_str) = precision.as_str() { + return Some(precision_str); + // event["queryStringParameters"]["precision"] may be an array. This is common from requests + // originating from AWS services, such as when the connector is ran with the cargo lambda watch command + } else if let Some(precision_array) = precision.as_array() { + if let Some(precision_value) = precision_array.first().and_then(|value| value.as_str()) + { + return Some(precision_value); } + } } None @@ -148,7 +152,6 @@ pub async fn lambda_handler( } #[cfg(test)] - #[test] pub fn test_get_precision_query_string_parameters_array() -> Result<(), Error> { let fake_event_value = json!({ "queryStringParameters": { "precision": ["ms"] } }); diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs index a257ff29..63244f8f 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs @@ -5,6 +5,9 @@ use std::collections::HashMap; mod multi_table_multi_measure_builder; +const DIMENSION_PARTITION_KEY_TYPE: &str = "dimension"; +const MEASURE_PARTITION_KEY_TYPE: &str = "measure"; + pub enum SchemaType { MultiTableMultiMeasure(String), } @@ -36,11 +39,62 @@ pub struct TableConfig { pub mag_store_retention_period: i64, pub mem_store_retention_period: i64, pub enable_mag_store_writes: bool, + pub enforce_custom_partition_key: Option, + pub custom_partition_key_type: Option, + pub custom_partition_key_dimension: Option, } pub fn get_table_config() -> Result { // Get the populated table_config struct + let custom_partition_key_type = match std::env::var("custom_partition_key_type") { + Ok(custom_partition_key_type_value) => { + match custom_partition_key_type_value.to_lowercase().as_str() { + DIMENSION_PARTITION_KEY_TYPE => { + Some(timestream_write::types::PartitionKeyType::Dimension) + } + MEASURE_PARTITION_KEY_TYPE => { + Some(timestream_write::types::PartitionKeyType::Measure) + } + _ => None, + } + } + _ => None, + }; + + // If custom_partition_key_type is "dimension", then enforce_custom_partition_key is required (true or false). + // If custom_partition_key_type is "measure", then this will ignore enforce_custom_partition_key. + // The SDK will return an error if custom_partition_key_type is "measure" and any value is specified for + // enforce_custom_partition_key + let enforce_custom_partition_key = match custom_partition_key_type { + Some(timestream_write::types::PartitionKeyType::Dimension) => { + // enforce_custom_partition_key value (true or false) is required if custom_partition_key_type is PartitionKeyType::Dimension + match std::env::var("enforce_custom_partition_key")? + .to_lowercase() + .as_str() + { + "true" | "t" | "1" => { + Some(timestream_write::types::PartitionKeyEnforcementLevel::Required) + } + "false" | "f" | "0" => { + Some(timestream_write::types::PartitionKeyEnforcementLevel::Optional) + } + _ => None, + } + } + _ => None, + }; + + // If custom_partition_key_type is "dimension", then custom_partition_key_dimension is required. + // The SDK will return an error if custom_partition_key_type is "measure" and + // any value is specified for custom_partition_key_dimension + let custom_partition_key_dimension = match custom_partition_key_type { + Some(timestream_write::types::PartitionKeyType::Dimension) => { + Some(std::env::var("custom_partition_key_dimension")?) + } + _ => None, + }; + Ok(TableConfig { mag_store_retention_period: std::env::var("mag_store_retention_period")?.parse()?, mem_store_retention_period: std::env::var("mem_store_retention_period")?.parse()?, @@ -50,6 +104,9 @@ pub fn get_table_config() -> Result { .as_str(), "true" | "t" | "1" ), + enforce_custom_partition_key, + custom_partition_key_type, + custom_partition_key_dimension, }) } @@ -121,6 +178,42 @@ pub fn validate_env_variables() -> Result<(), Error> { } } + // Customer-defined partition key environment variables + let custom_partition_key_type = std::env::var("custom_partition_key_type"); + + if let Ok(custom_partition_key_type) = custom_partition_key_type { + if custom_partition_key_type != DIMENSION_PARTITION_KEY_TYPE + && custom_partition_key_type != MEASURE_PARTITION_KEY_TYPE + { + return Err(anyhow!( + format!("custom_partition_key_type can only be {DIMENSION_PARTITION_KEY_TYPE} or {MEASURE_PARTITION_KEY_TYPE}") + )); + } + + // Check required environment variables for when custom partition key type is "dimension." If it is "measure," + // no other environment variables are necessary. + + let custom_partition_key_dimension = std::env::var("custom_partition_key_dimension"); + + if custom_partition_key_type == DIMENSION_PARTITION_KEY_TYPE + && custom_partition_key_dimension.is_err() + { + return Err(anyhow!( + format!("If custom_partition_key_type is {DIMENSION_PARTITION_KEY_TYPE}, then custom_partition_key_dimension must be defined") + )); + } + + let enforce_custom_partition_key = std::env::var("enforce_custom_partition_key"); + + if custom_partition_key_type == DIMENSION_PARTITION_KEY_TYPE + && enforce_custom_partition_key.is_err() + { + return Err(anyhow!( + format!("enforce_custom_partition_key value must be specified (true or false) when custom_partition_key_type is {DIMENSION_PARTITION_KEY_TYPE}") + )); + } + } + Ok(()) } diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs index 63c810f3..ab60790e 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs @@ -61,8 +61,26 @@ pub async fn create_table( .set_enable_magnetic_store_writes(Some(table_config.enable_mag_store_writes)) .build()?; + // Customer-defined partition key configuration + let table_schema = if table_config.custom_partition_key_type.is_some() { + let partition_key = timestream_write::types::PartitionKey::builder() + .set_type(table_config.custom_partition_key_type) + .set_name(table_config.custom_partition_key_dimension) + .set_enforcement_in_record(table_config.enforce_custom_partition_key) + .build()?; + + Some( + timestream_write::types::Schema::builder() + .set_composite_partition_key(Some(vec![partition_key])) + .build(), + ) + } else { + None + }; + client .create_table() + .set_schema(table_schema) .set_table_name(Some(table_name.to_owned())) .set_database_name(Some(database_name.to_owned())) .set_retention_properties(Some(retention_properties)) diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml index a4ddbc5c..f0d6df89 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml +++ b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml @@ -12,6 +12,18 @@ Metadata: SemanticVersion: 1.0.0 Parameters: + CustomPartitionKeyDimension: + Description: The dimension to use as the partition key. This parameter is required if the CustomPartitionKeyType parameter is set to 'dimension'. + Type: String + Default: '' + CustomPartitionKeyType: + Description: The type of custom partition key to use. Valid options are 'dimension' or 'measure'. The 'dimension' option requires the CustomPartitionKeyDimension parameter to also be set. If this parameter is not provided, newly-created tables will use default partitioning and none of the parameters relating to custom partition keys will be used. + Type: String + AllowedValues: + - 'dimension' + - 'measure' + - '' + Default: '' DatabaseName: Type: String Default: influxdb-line-protocol @@ -22,17 +34,19 @@ Parameters: Description: The value to use in records as the measure name. EnableAsyncInvocation: Type: String - Default: 'true' AllowedValues: - 'true' - 'false' + Default: 'true' EnableDatabaseCreation: + Description: Whether to allow database creation upon ingestion of records. Type: String AllowedValues: - 'true' - 'false' Default: 'true' EnableTableCreation: + Description: Whether to allow table creation upon ingestion of records. When using multi-table multi measure schema, each unique line protocol measurement in a request will result in the creation of a new table with the same name as the measurement. Type: String AllowedValues: - 'true' @@ -44,6 +58,14 @@ Parameters: - 'true' - 'false' Default: 'true' + EnforceCustomPartitionKey: + Description: Whether to only allow the ingestion of records that contain the custom partition key. + Type: String + AllowedValues: + - 'true' + - 'false' + - '' + Default: '' MagStoreRetentionPeriod: Type: Number Default: 8000 @@ -63,6 +85,10 @@ Parameters: MinValue: 128 MaxValue: 8192 Description: The memory size of the Lambda function. + LambdaName: + Type: String + AllowedPattern: '[a-zA-Z0-9]+[a-zA-Z0-9-]+[a-zA-Z0-9]+' + Default: influxdb-timestream-connector-lambda RestApiGatewayTimeoutInMillis: Type: Number MinValue: 2 @@ -79,8 +105,11 @@ Parameters: Description: The number of burst requests per second that the REST API Gateway permits. Conditions: - IsAsyncEnabled: !Equals [ !Ref EnableAsyncInvocation, "true" ] - IsSyncEnabled: !Equals [ !Ref EnableAsyncInvocation, "false" ] + IsAsyncEnabled: !Equals [ !Ref EnableAsyncInvocation, 'true' ] + IsSyncEnabled: !Equals [ !Ref EnableAsyncInvocation, 'false' ] + CustomPartitionKeyTypeProvided: !Not [ !Equals [ !Ref CustomPartitionKeyType, '' ] ] + CustomPartitionKeyDimensionProvided: !Not [ !Equals [ !Ref CustomPartitionKeyDimension, '' ] ] + EnforceCustomPartitionKeyProvided: !Not [ !Equals [ !Ref EnforceCustomPartitionKey, '' ] ] Resources: RestApiGateway: @@ -392,7 +421,7 @@ Resources: RestApiGatewayLogGroup: Type: AWS::Logs::LogGroup Properties: - LogGroupName: !Sub "/aws/apigateway/${RestApiGateway.RestApiId}" + LogGroupName: !Sub "/aws/apigateway/${RestApiGatewayName}" RetentionInDays: 14 RestApiGatewayLogsRole: @@ -504,12 +533,15 @@ Resources: MaximumRetryAttempts: 0 Environment: Variables: + custom_partition_key_type: !If [CustomPartitionKeyTypeProvided, !Ref CustomPartitionKeyType, !Ref "AWS::NoValue"] + custom_partition_key_dimension: !If [CustomPartitionKeyDimensionProvided, !Ref CustomPartitionKeyDimension, !Ref "AWS::NoValue"] database_name: !Ref DatabaseName measure_name_for_multi_measure_records: !Ref MeasureNameForMultiMeasureRecords region: !Ref AWS::Region enable_database_creation: !Ref EnableDatabaseCreation enable_table_creation: !Ref EnableTableCreation enable_mag_store_writes: !Ref EnableMagStoreWrites + enforce_custom_partition_key: !If [EnforceCustomPartitionKeyProvided, !Ref EnforceCustomPartitionKey, !Ref "AWS::NoValue"] mag_store_retention_period: !Ref MagStoreRetentionPeriod mem_store_retention_period: !Ref MemStoreRetentionPeriod PackageType: Zip diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs b/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs index ad6867ea..7119a8f3 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs @@ -19,10 +19,7 @@ struct CleanupBatch { } impl CleanupBatch { - pub fn new( - database_name: String, - table_names_to_delete: Vec, - ) -> CleanupBatch { + pub fn new(database_name: String, table_names_to_delete: Vec) -> CleanupBatch { CleanupBatch { database_name, table_names_to_delete, @@ -72,6 +69,9 @@ fn set_environment_variables() { "measure_name_for_multi_measure_records", "test_measure_name", ); + env::remove_var("custom_partition_key_type"); + env::remove_var("custom_partition_key_dimension"); + env::remove_var("enforce_custom_partition_key"); } fn random_string(n: usize) -> String { @@ -116,14 +116,14 @@ async fn test_mtmm_basic() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -156,8 +156,10 @@ async fn test_mtmm_create_database() -> Result<(), Error> { let response = influxdb_timestream_connector::lambda_handler(&client, request).await; println!("Response: {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(test_create_database_name.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new( + test_create_database_name.to_string(), + vec![lp_measurement_name], + ); cleanup_batch.cleanup(&client).await; let database_delete_response = client .delete_database() @@ -165,7 +167,10 @@ async fn test_mtmm_create_database() -> Result<(), Error> { .send() .await; if database_delete_response.is_err() { - println!("The database {} failed to delete", test_create_database_name); + println!( + "The database {} failed to delete", + test_create_database_name + ); } assert!(response.is_ok()); @@ -197,14 +202,14 @@ async fn test_mtmm_unusual_query_parameters() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -228,14 +233,14 @@ async fn test_mtmm_no_query_parameters() -> Result<(), Error> { let request = LambdaEvent::::new(json!({ "body": point }), Context::default()); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -267,12 +272,11 @@ async fn test_mtmm_multiple_timestamps() -> Result<(), Error> { ); let response = influxdb_timestream_connector::lambda_handler(&client, request).await; - assert!(response.is_err()); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_err()); Ok(()) } @@ -309,17 +313,17 @@ async fn test_mtmm_many_tags_many_fields() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -347,14 +351,14 @@ async fn test_mtmm_float() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -382,14 +386,14 @@ async fn test_mtmm_string() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -417,14 +421,14 @@ async fn test_mtmm_bool() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -456,14 +460,14 @@ async fn test_mtmm_max_tag_length() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -496,12 +500,11 @@ async fn test_mtmm_beyond_max_tag_length() -> Result<(), Error> { ); let response = influxdb_timestream_connector::lambda_handler(&client, request).await; - assert!(response.is_err()); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_err()); Ok(()) } @@ -532,14 +535,14 @@ async fn test_mtmm_max_field_length() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -571,12 +574,11 @@ async fn test_mtmm_beyond_max_field_length() -> Result<(), Error> { ); let response = influxdb_timestream_connector::lambda_handler(&client, request).await; - assert!(response.is_err()); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_err()); Ok(()) } @@ -611,14 +613,14 @@ async fn test_mtmm_max_unique_field_keys() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -655,12 +657,11 @@ async fn test_mtmm_beyond_max_unique_field_keys() -> Result<(), Error> { let response = influxdb_timestream_connector::lambda_handler(&client, request).await; println!("Response: {:?}", response); - assert!(response.is_err()); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_err()); Ok(()) } @@ -695,14 +696,14 @@ async fn test_mtmm_max_unique_tag_keys() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -739,12 +740,11 @@ async fn test_mtmm_beyond_max_unique_tag_keys() -> Result<(), Error> { let response = influxdb_timestream_connector::lambda_handler(&client, request).await; println!("Response: {:?}", response); - assert!(response.is_err()); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_err()); Ok(()) } @@ -774,14 +774,14 @@ async fn test_mtmm_max_table_name_length() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -812,12 +812,11 @@ async fn test_mtmm_beyond_max_table_name_length() -> Result<(), Error> { ); let response = influxdb_timestream_connector::lambda_handler(&client, request).await; - assert!(response.is_err()); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_err()); Ok(()) } @@ -847,14 +846,14 @@ async fn test_mtmm_nanosecond_precision() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -882,14 +881,14 @@ async fn test_mtmm_microsecond_precision() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -917,14 +916,14 @@ async fn test_mtmm_second_precision() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -955,15 +954,14 @@ async fn test_mtmm_no_precision() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -982,11 +980,11 @@ async fn test_mtmm_empty_point() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -1014,14 +1012,14 @@ pub async fn test_mtmm_small_timestamp() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -1057,14 +1055,14 @@ async fn test_mtmm_5_measurements() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), table_names_to_delete); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), table_names_to_delete); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -1100,14 +1098,14 @@ async fn test_mtmm_100_measurements() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), table_names_to_delete); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), table_names_to_delete); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -1141,14 +1139,14 @@ async fn test_mtmm_5000_batch() -> Result<(), Error> { Context::default(), ); - let response = influxdb_timestream_connector::lambda_handler(&client, request).await?; - println!("Response: {}: {:?}", response["statusCode"], response["body"]); - assert!(response["statusCode"] == 200); + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); - let mut cleanup_batch = - CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); cleanup_batch.cleanup(&client).await; + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); Ok(()) } @@ -1231,3 +1229,315 @@ async fn test_mtmm_incorrect_credentials() { let _ = influxdb_timestream_connector::lambda_handler(&client, request).await; } + +#[tokio::test] +async fn test_mtmm_custom_dimension_partition_key_optional_enforcement() -> Result<(), Error> { + // Tests ingesting a single point and specifying a valid configuration for + // a custom dimension partition key with optional enforcement. + set_environment_variables(); + env::set_var("custom_partition_key_type", "dimension"); + env::set_var("custom_partition_key_dimension", "nomatch"); + env::set_var("enforce_custom_partition_key", "false"); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); + + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; + + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_custom_dimension_partition_key_required_enforcement_accepted( +) -> Result<(), Error> { + // Tests ingesting a single point and specifying a valid configuration for + // a custom dimension partition key with required enforcement and a successful ingestion. + set_environment_variables(); + env::set_var("custom_partition_key_type", "dimension"); + env::set_var("custom_partition_key_dimension", "tag1"); + env::set_var("enforce_custom_partition_key", "true"); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); + + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; + + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_custom_dimension_partition_key_required_enforcement_rejected( +) -> Result<(), Error> { + // Tests ingesting a single point and specifying a valid configuration for + // a custom dimension partition key with required enforcement and an unsuccessful ingestion. + set_environment_variables(); + env::set_var("custom_partition_key_type", "dimension"); + env::set_var("custom_partition_key_dimension", "nomatch"); + env::set_var("enforce_custom_partition_key", "true"); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response: {:?}", response); + + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; + + assert!(response.is_err()); + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_custom_dimension_partition_key_no_dimension() -> Result<(), Error> { + // Tests ingesting a single point and specifying a configuration for + // a custom dimension partition key without a dimension specified. + set_environment_variables(); + env::set_var("custom_partition_key_type", "dimension"); + env::remove_var("custom_partition_key_dimension"); + env::set_var("enforce_custom_partition_key", "false"); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response: {:?}", response); + + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; + + assert!(response.is_err()); + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_custom_dimension_partition_key_no_enforcement() -> Result<(), Error> { + // Tests ingesting a single point and specifying a configuration for + // a custom dimension partition key without an enforcement configuration specified. + set_environment_variables(); + env::set_var("custom_partition_key_type", "dimension"); + env::set_var("custom_partition_key_dimension", "tag1"); + env::remove_var("enforce_custom_partition_key"); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response: {:?}", response); + + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; + + assert!(response.is_err()); + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_custom_measure_partition_key() -> Result<(), Error> { + // Tests ingesting a single point and specifying a valid configuration for + // a custom measure partition key. + set_environment_variables(); + env::set_var("custom_partition_key_type", "measure"); + env::remove_var("custom_partition_key_dimension"); + env::remove_var("enforce_custom_partition_key"); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response: {:?}", response); + + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; + + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_custom_measure_partition_key_with_dimension() -> Result<(), Error> { + // Tests ingesting a single point and specifying a valid configuration for + // a custom measure partition key with a dimension specified. The dimension should be ignored. + set_environment_variables(); + env::set_var("custom_partition_key_type", "measure"); + env::set_var("custom_partition_key_dimension", "should_be_ignored"); + env::remove_var("enforce_custom_partition_key"); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response: {:?}", response); + + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; + + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); + Ok(()) +} + +#[tokio::test] +async fn test_mtmm_custom_measure_partition_key_with_enforcement() -> Result<(), Error> { + // Tests ingesting a single point and specifying a valid configuration for + // a custom measure partition key with an enforcement configuration specified. + // The enforcement configuration should be ignored. + set_environment_variables(); + env::set_var("custom_partition_key_type", "measure"); + env::remove_var("custom_partition_key_dimension"); + env::set_var("enforce_custom_partition_key", "false"); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response: {:?}", response); + + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; + + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); + Ok(()) +} From 3070ee6c136af12ac9810b0b4c896adb1fda58fc Mon Sep 17 00:00:00 2001 From: Trevor Bonas <45324987+trevorbonas@users.noreply.github.com> Date: Fri, 11 Oct 2024 10:40:36 -0700 Subject: [PATCH 05/11] Improve IAM permissions *Issue #, if available:* N/A. *Description of changes:* - Deployment permissions have been updated according to the required permissions as discovered by testing deploying the connector using its SAM template. - `samconfig.toml` added with default stack deployment options. - "Troubleshooting" section added to the README with two known errors users may encounter. - Issue with cross-platform Rust compilation. - ConflictExceptions occurring with concurrent instances of the connector. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. --- .../influxdb-timestream-connector/README.md | 151 ++++++++++++++++-- .../samconfig.toml | 10 ++ .../template.yml | 4 +- 3 files changed, 149 insertions(+), 16 deletions(-) create mode 100644 integrations/influxdb_connector/influxdb-timestream-connector/samconfig.toml diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/README.md b/integrations/influxdb_connector/influxdb-timestream-connector/README.md index f43a01e7..70b68398 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/README.md +++ b/integrations/influxdb_connector/influxdb-timestream-connector/README.md @@ -82,15 +82,14 @@ The following parameters are available when deploying the connector as part of a ``` cargo lambda build --release --arm64 --output-format zip ``` -5. Run the following command, replacing `` with the AWS region you want to deploy in, `` with your desired stack name and providing parameter overrides as desired. Note that this example command uses `--resolve-s3` to automatically create an S3 bucket to use for packaging and deployment and `--capabilities CAPABILITY_IAM` to allow the creation of IAM roles. +5. Run the following command, replacing `` with the AWS region you want to deploy in and providing parameter overrides as desired. Note that this example command uses the provided `samconfig.toml` file and by default sets the name of the stack to `InfluxDBTimestreamConnector`. ```shell sam deploy template.yml \ --region \ - --stack-name \ - --resolve-s3 \ - --capabilities CAPABILITY_IAM \ - --parameter-overrides ParameterKey=exampleKey,ParameterValue=exampleValue + --parameter-overrides \ + ParameterKey1=ParameterValue1 \ + ParameterKey2=ParameterValue2 ``` 6. Once the stack has finished deploying, take note of the output `Endpoint` value. This value will be used as the endpoint for all write requests and is analogous to an [InfluxDB host address](https://docs.influxdata.com/influxdb/v2/reference/urls/) and is used in the same way, for example, `/api/v2/write`. @@ -213,16 +212,107 @@ The REST API Gateway ensures all requests are authenticated with SigV4. Any Infl ## IAM Permissions -### IAM Deployment Permissions +The following permissions are the least-privilege permissions for deploying and executing the connector. These permissions assume the stack is named `InfluxDBTimestreamConnector` and that the REST API Gateway is named `InfluxDB-Timestream-Connector-REST-API-Gateway`. -[//]: # (TODO: Update deployment policy with least privilege once REST API Gateway deployment has been tested.) +### IAM Deployment Permissions -The following is the least privileged IAM permissions for deploying the connector. +The following is the least-privilege IAM permissions for deploying the connector. ```json { "Version": "2012-10-17", "Statement": [ + { + "Effect": "Allow", + "Action": [ + "apigateway:PATCH" + ], + "Resource": "arn:aws:apigateway:{region}::/account" + }, + { + "Effect": "Allow", + "Action": [ + "apigateway:POST", + "apigateway:GET" + ], + "Resource": [ + "arn:aws:apigateway:{region}::/usageplans", + "arn:aws:apigateway:{region}::/usageplans/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "sqs:CreateQueue", + "sqs:GetQueueAttributes" + ], + "Resource": "arn:aws:sqs:{region}:{account-id}:InfluxDBTimestreamConnector-LambdaDeadLetterQueue-*" + }, + { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:PutRetentionPolicy" + ], + "Resource": "arn:aws:logs:{region}:{account-id}:log-group:/aws/apigateway/InfluxDBTimestreamConnector-InfluxDB-Timestream-Connector-REST-API-Gateway*:*" + }, + { + "Effect": "Allow", + "Action": [ + "logs:DescribeLogGroups" + ], + "Resource": "arn:aws:logs:{region}:{account-id}:log-group::log-stream:" + }, + { + "Effect": "Allow", + "Action": [ + "apigateway:POST", + "apigateway:GET", + "apigateway:PUT", + "apigateway:PATCH" + ], + "Resource": [ + "arn:aws:apigateway:{region}::/restapis/*", + "arn:aws:apigateway:{region}::/restapis", + "arn:aws:apigateway:{region}::/tags/*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:GetBucketPolicy", + "s3:GetBucketLocation", + "s3:PutObject", + "s3:PutBucketPolicy", + "s3:PutBucketTagging", + "s3:PutEncryptionConfiguration", + "s3:PutBucketVersioning", + "s3:PutBucketPublicAccessBlock", + "s3:CreateBucket", + "s3:DescribeJob", + "s3:ListAllMyBuckets" + ], + "Resource": [ + "arn:aws:s3:::aws-sam-cli-managed-default*" + ] + }, + { + "Effect": "Allow", + "Action": [ + "cloudformation:CreateChangeSet", + "cloudformation:DescribeStacks", + "cloudformation:DescribeStackEvents", + "cloudformation:DescribeChangeSet", + "cloudformation:ExecuteChangeSet", + "cloudformation:CreateStack" + ], + "Resource": [ + "arn:aws:cloudformation:{region}:{account-id}:stack/InfluxDBTimestreamConnector/*", + "arn:aws:cloudformation:{region}:{account-id}:stack/aws-sam-cli-managed-default/*", + "arn:aws:cloudformation:{region}:aws:transform/Serverless-2016-10-31" + ] + }, { "Effect": "Allow", "Action": [ @@ -230,11 +320,12 @@ The following is the least privileged IAM permissions for deploying the connecto "iam:AttachRolePolicy", "iam:UpdateAssumeRolePolicy", "iam:PassRole", - "iam:PutRolePolicy" + "iam:PutRolePolicy", + "iam:GetRole" ], "Resource": [ - "arn:aws:iam::{account-id}:role/AWSLambdaBasicExecutionRole", - "arn:aws:iam::{account-id}:role/cargo-lambda-role*" + "arn:aws:iam::{account-id}:role/InfluxDBTimestreamConnector-RestApiGatewayLogsRole-*", + "arn:aws:iam::{account-id}:role/InfluxDBTimestreamConnector-LambdaExecutionRole-*" ] }, { @@ -245,10 +336,13 @@ The following is the least privileged IAM permissions for deploying the connecto "lambda:GetFunction", "lambda:UpdateFunctionConfiguration", "lambda:GetFunctionConfiguration", - "lambda:CreateFunctionUrlConfig" + "lambda:CreateFunctionUrlConfig", + "lambda:TagResource", + "lambda:AddPermission", + "lambda:PutFunctionEventInvokeConfig" ], - "Resource": "arn:aws:lambda:{region}:{account-id}:function:influxdb-timestream-connector" - } + "Resource": "arn:aws:lambda:{region}:{account-id}:function:InfluxDBTimestreamConnector-LambdaFunction-*" + }, ] } ``` @@ -401,6 +495,35 @@ Due to the connector translating line protocol to Timestream records, line proto There is a delay of one second added before deleting or creating a table or database. This is because of Timestream for LiveAnalytics' "Throttle rate for CRUD APIs" [quota](https://docs.aws.amazon.com/timestream/latest/developerguide/ts-limits.html#limits.default) of one table/database deletion/creation per second. +## Troubleshooting + +### Cargo Lambda Build Error "can't find crate for core" + +This error can happen when running `cargo lambda build` on macOS. This error may include the message "the `aarch64-unknown-linux-gnu` target may not be installed." + +#### Solution + +This error can happen on macOS when Rust is installed with `brew`. + +Remove `brew`'s version of Rust: + +```shell +brew uninstall rust +``` + +Install Rust by following the installation instructions on its [official site](https://www.rust-lang.org/tools/install). + +### Table Already Exists Error + +Error in full: ConflictException: Timestream was unable to process this request because it contains resource that already exists. + +When using multi-table multi measure schema and ingesting line protocol data in parallel with measurements that do not yet have corresponding tables in Timestream for LiveAnalytics, a ConflictException can occur. This happens when two or more concurrent Lambda function instances attempt to create a new table. + +#### Solution + +1. Consider creating the tables before ingestion, using each unique measurement in the line protocol data as the table names. +2. Re-ingest failed requests. Failed requests will be stored in the Lambda's dead letter queue. + ## Caveats ### Line Protocol Tag Requirement diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/samconfig.toml b/integrations/influxdb_connector/influxdb-timestream-connector/samconfig.toml new file mode 100644 index 00000000..38ec3da8 --- /dev/null +++ b/integrations/influxdb_connector/influxdb-timestream-connector/samconfig.toml @@ -0,0 +1,10 @@ +version = 0.1 +[default] +[default.deploy] +[default.deploy.parameters] +capabilities = "CAPABILITY_IAM" +confirm_changeset = true +profile = "default" +region = "us-east-1" +stack_name = "InfluxDBTimestreamConnector" +resolve_s3 = true diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml index f0d6df89..ca371658 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml +++ b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml @@ -421,7 +421,7 @@ Resources: RestApiGatewayLogGroup: Type: AWS::Logs::LogGroup Properties: - LogGroupName: !Sub "/aws/apigateway/${RestApiGatewayName}" + LogGroupName: !Sub "/aws/apigateway/${AWS::StackName}-${RestApiGatewayName}" RetentionInDays: 14 RestApiGatewayLogsRole: @@ -468,7 +468,7 @@ Resources: - logs:PutLogEvents Effect: Allow Resource: - - !Sub arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/* + - !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${AWS::StackName}-LambdaFunction-*:*" - PolicyName: LambdaSQSDlqPolicy PolicyDocument: Version: 2012-10-17 From 996d3f7839de7b830c4abb5122d1ceb68314d197 Mon Sep 17 00:00:00 2001 From: Trevor Bonas <45324987+trevorbonas@users.noreply.github.com> Date: Fri, 11 Oct 2024 12:48:06 -0700 Subject: [PATCH 06/11] Return 2.0 formatted Lambda responses for local invocation - The environment variable `local_invocation` has been added and is set to `true` by default when the Lambda function is run with `cargo lambda watch`. This environment enables responses to be returned in a format `cargo lambda` expects, in the 2.0 formatting. Otherwise, the 1.0 format will be returned, which the synchronous API Gateway expects. - `cargo fmt` run. - [x] Tested locally. - [x] Tested with synchronous invocation. - [x] Tested with asynchronous invocation. --- .../influxdb-timestream-connector/Cargo.toml | 1 + .../influxdb-timestream-connector/src/lib.rs | 30 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml index 79acf003..16941ea9 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml +++ b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml @@ -32,3 +32,4 @@ enable_table_creation = "true" enable_mag_store_writes = "true" mag_store_retention_period = "8000" mem_store_retention_period = "12" +local_invocation = "true" diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs index ff5d6d1a..f50505f8 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs @@ -133,17 +133,27 @@ pub async fn lambda_handler( .as_bytes(); match handle_body(client, data, &precision).await { - // This is the format required for custom Lambda responses + // This is the format required for custom Lambda 1.0 responses // https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html - Ok(_) => Ok(json!({ - "statusCode": 200, - "body": "{\"message\": \"Success\"}", - "isBase64Encoded": false, - "headers": { - "Content-Type": "application/json" - }, - "cookies": [] - })), + Ok(_) => { + let mut response = json!({ + "statusCode": 200, + "body": "{\"message\": \"Success\"}", + "isBase64Encoded": false, + "headers": { + "Content-Type": "application/json" + } + }); + // cargo lambda watch expects a Lambda response in 2.0 format. + // This means a "cookies" array must be added to the response. + // If this "cookies" array is present and the connector is deployed + // with synchronous invocation in a stack, users will receive a + // 502 error + if std::env::var("local_invocation").is_ok() { + response["cookies"] = json!([]); + } + Ok(response) + } // An Err is required in order to send messages to the Lambda's // dead letter queue, when the connector is deployed as part of a stack // with asynchronous invocation From 0e6fdf7e07b7410ccef6f1befa0a5c4da00a2edc Mon Sep 17 00:00:00 2001 From: Trevor Bonas <45324987+trevorbonas@users.noreply.github.com> Date: Tue, 22 Oct 2024 15:59:42 -0700 Subject: [PATCH 07/11] Optimize connector *Issue #, if available:* N/A. *Description of changes:* - Ingestion to multiple tables done in parallel. - Ingestion of 100 records to a single table done in parallel. - Chunking of records into batches of 100 done in parallel. - Limit on maximum possible number of threads added. - Print statement for each 100 records removed. - Logging option added to SAM template. - All `println!` calls changed to `info!`. - Default logging level for the connector changed to `INFO`. - Trace statements that measure the execution time of each function added. - Instructions added to README for how to configure logging levels. - Database and tables are no longer checked if their corresponding environment variables for creation are not set. - The checking of whether tables exist has been moved within the asynchronous code block used to ingest records to a table. This checking is now done in parallel and the hashmap is looped through once, instead of twice. - Instructions added to README for how to reduce stack costs. - [x] Integration tests passed. - [x] Unit tests passed. By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. --- .../influxdb-timestream-connector/Cargo.lock | 220 +++++++++++++++--- .../influxdb-timestream-connector/Cargo.toml | 9 +- .../influxdb-timestream-connector/README.md | 50 +++- .../influxdb-timestream-connector/src/lib.rs | 125 +++++++--- .../src/line_protocol_parser.rs | 3 + .../influxdb-timestream-connector/src/main.rs | 34 ++- .../src/records_builder.rs | 16 +- .../multi_table_multi_measure_builder.rs | 11 + .../src/timestream_utils.rs | 122 +++++++--- .../template.yml | 5 + .../tests/integration_test.rs | 39 ++++ .../go/line-protocol-client-demo.go | 2 +- 12 files changed, 541 insertions(+), 95 deletions(-) diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.lock b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.lock index 9b13d613..9c8a59d8 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.lock +++ b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.lock @@ -59,6 +59,55 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" + +[[package]] +name = "anstyle-parse" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" +dependencies = [ + "anstyle", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.86" @@ -619,6 +668,12 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "colorchoice" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" + [[package]] name = "const-oid" version = "0.9.6" @@ -668,6 +723,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" + [[package]] name = "crypto-bigint" version = "0.4.9" @@ -768,6 +848,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "env_filter" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2c92ceda6ceec50f43169f9ee8424fe2db276791afde7b2cd8bc084cb376ab" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13fa619b91fb2381732789fc5de83b45675e882f66623b7d8cb4f643017018d" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "humantime", + "log", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -813,9 +916,9 @@ checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -828,9 +931,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -838,15 +941,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -855,15 +958,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", @@ -872,21 +975,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -1072,6 +1175,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.29" @@ -1218,14 +1327,26 @@ dependencies = [ "aws-sdk-timestreamwrite", "aws-types", "chrono", + "env_logger", + "futures", "influxdb-line-protocol", "lambda_runtime", + "log", "rand", + "rayon", "serde", "serde_json", "tokio", + "tracing", + "tracing-subscriber", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + [[package]] name = "itoa" version = "1.0.11" @@ -1287,8 +1408,6 @@ dependencies = [ "tokio", "tower", "tower-service", - "tracing", - "tracing-subscriber", ] [[package]] @@ -1390,6 +1509,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -1441,6 +1570,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4030760ffd992bef45b0ae3f10ce1aba99e33464c90d14dd7c039884963ddc7a" +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "p256" version = "0.11.1" @@ -1584,6 +1719,26 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.2" @@ -2068,9 +2223,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267ac89e0bec6e691e5813911606935d77c476ff49024f98abcea3e7b15e37af" +checksum = "4f4e6ce100d0eb49a2734f8c0812bcd324cf357d21810932c5df6b96ef2b86f1" dependencies = [ "futures-core", "pin-project-lite", @@ -2152,12 +2307,13 @@ dependencies = [ ] [[package]] -name = "tracing-serde" -version = "0.1.3" +name = "tracing-log" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6b213177105856957181934e4920de57730fc69bf42c37ee5bb664d406d9e1" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ - "serde", + "log", + "once_cell", "tracing-core", ] @@ -2168,15 +2324,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" dependencies = [ "matchers", + "nu-ansi-term", "once_cell", "regex", - "serde", - "serde_json", "sharded-slab", + "smallvec", "thread_local", "tracing", "tracing-core", - "tracing-serde", + "tracing-log", ] [[package]] @@ -2235,6 +2391,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.9.1" diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml index 16941ea9..6b827e4d 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml +++ b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" [dependencies.lambda_runtime] version = "0.13.0" default-features = false -features = ["anyhow", "tracing"] +features = ["anyhow"] [dependencies] anyhow = "1.0.86" @@ -17,11 +17,17 @@ aws-sdk-s3 = "1.46.0" aws-sdk-timestreamwrite = "1.39.0" aws-types = "1.3.3" chrono = "0.4.38" +env_logger = "0.11.5" +futures = "0.3.31" influxdb-line-protocol = { git = "https://github.com/influxdata/influxdb3_core", rev = "d81f63ddc10e3cf1c28b05e6c1cef03b71da7f8a" } +log = "0.4.22" rand = "0.5.0" +rayon = "1.10.0" serde = { version = "1.0.208", features = ["derive"] } serde_json = "1.0.1" tokio = { version = "1.39.3", features = ["full"] } +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } [package.metadata.lambda.env] region = "us-east-1" @@ -33,3 +39,4 @@ enable_mag_store_writes = "true" mag_store_retention_period = "8000" mem_store_retention_period = "12" local_invocation = "true" + diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/README.md b/integrations/influxdb_connector/influxdb-timestream-connector/README.md index 70b68398..164b8229 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/README.md +++ b/integrations/influxdb_connector/influxdb-timestream-connector/README.md @@ -71,6 +71,7 @@ The following parameters are available when deploying the connector as part of a | `RestApiGatewayName` | The name to use for the REST API Gateway. | `InfluxDB-Timestream-Connector-REST-API-Gateway` | | `RestApiGatewayStageName` | The name to use for the REST API Gateway stage. | `dev` | | `RestApiGatewayTimeoutInMillis` | The maximum number of milliseconds a REST API Gateway event will wait before timing out. | `30000` | +| `RustLog` | The log level to use for the Lambda function. Typical values are error, warn, info, debug, trace, and off. Use trace in order to log the execution time of each function. | `INFO` | | `WriteThrottlingBurstLimit` | The number of burst requests per second that the REST API Gateway permits. | `1200` | ##### SAM Deployment Steps @@ -495,6 +496,27 @@ Due to the connector translating line protocol to Timestream records, line proto There is a delay of one second added before deleting or creating a table or database. This is because of Timestream for LiveAnalytics' "Throttle rate for CRUD APIs" [quota](https://docs.aws.amazon.com/timestream/latest/developerguide/ts-limits.html#limits.default) of one table/database deletion/creation per second. +## Logging + +Logging levels for the connector can be configured using the `RUST_LOG` [Rust environment variable](https://docs.rs/env_logger/latest/env_logger/#enabling-logging). By default, the logging level the connector uses is `INFO`. + +The `TRACE` logging level displays execution time for each function in the connector. + +### Enabling Trace Logging for Local Deployment + +The following command will run the connector with its logging level set to `TRACE` and output function durations to a file: + +```shell +cargo lambda watch \ + --env-var RUST_LOG=TRACE 2>&1 | \ + tee /dev/tty | \ + grep --line-buffered "TRACE.*influxdb_timestream_connector" > function_duration.log +``` + +### Enabling Trace Logging for CloudFormation Deployment + +The parameter `RustLog` allows configuration of the `RUST_LOG` environment variable for the Lambda function. + ## Troubleshooting ### Cargo Lambda Build Error "can't find crate for core" @@ -524,6 +546,33 @@ When using multi-table multi measure schema and ingesting line protocol data in 1. Consider creating the tables before ingestion, using each unique measurement in the line protocol data as the table names. 2. Re-ingest failed requests. Failed requests will be stored in the Lambda's dead letter queue. +### Stack Cost + +The following is an overview of how the costs are calculated for each deployed resource in the CloudFormation stack, at the time of writing (Oct. 21, 2024). These calculations are based on the calculations used by the [AWS pricing calculator](https://calculator.aws/#/). Refer to the AWS pricing calculator for a more accurate estimate: + +- Lambda (without free tier): + - number of monthly requests x average request duration x 0.001 ms to sec conversion factor = total compute in seconds. + - memory usage in GB x total compute in seconds = total compute GB-s. + - total compute GB-s x 0.0000133334 USD = tiered price. + - number of monthly requests x 0.0000002 USD = monthly request charges. + - tiered price + monthly request charges = Lambda monthly cost. +- REST API Gateway: + - number of monthly requests x 0.0000035 USD = REST API Gateway monthly cost. +- CloudWatch: + - logs data ingested in GB per month x 0.50 USD = logs data ingested cost per month. + - logs data ingested in GB per month x 0.15 Storage compression factor x 1 Logs retention factor x 0.03 USD = standard/vended logs data storage cost. + - logs data ingested cost + standard/vended logs data storage cost = CloudWatch monthly cost. + +#### Solution + +The following are some approaches that can reduce stack costs: + +- The REST API Gateway incurs the highest costs for the stack. Reduce REST API Gateway costs by including as many line protocol points in a request as possible. 5,000-20,000 line protocol points in each request is ideal. The Lambda memory size, Lambda timeout, REST API Gateway timeout, and client timeout may have to be increased in order to accommodate larger requests. + - NOTE: if requests contain only a single line protocol point, the ratio of REST API Gateway requests to ingested records would be 1:1 and costs would be much higher than including multiple line protocol points in each request. +- If possible, create the necessary tables in advance, to reduce the time the connector will take to create tables. The connector adds a one second delay for table or database creation in order to avoid throttling. +- Reduce the amount of memory the connector uses as a Lambda function. The default, and smallest possible value, is 128 MB. +- Set the `EnableDatabaseCreation` or `EnableTableCreation` parameter to `false` to skip checks for existing databases or tables, if unnecessary. + ## Caveats ### Line Protocol Tag Requirement @@ -533,4 +582,3 @@ In order to ingest to Timestream for LiveAnalytics, every line protocol point mu ### Query String Parameters The connector expects query string parameters to be included as `queryParameters` or `queryStringParameters` in requests. - diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs index f50505f8..0266ac6c 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs @@ -1,12 +1,19 @@ use anyhow::{anyhow, Error, Result}; use aws_sdk_timestreamwrite as timestream_write; +use futures::stream::FuturesUnordered; +use futures::StreamExt; use lambda_runtime::LambdaEvent; use line_protocol_parser::*; +use log::{info, trace}; use records_builder::*; use serde_json::{json, Value}; use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; use std::{str, thread, time}; use timestream_utils::*; +use tokio::sync::Semaphore; +use tokio::task; pub mod line_protocol_parser; pub mod metric; @@ -17,8 +24,14 @@ pub mod timestream_utils; // that can be made per second is 1. pub static TIMESTREAM_API_WAIT_SECONDS: u64 = 1; +// The number of batches processed at the same time. +// For multi-table multi measure schema, batches are a combination of +// a table name and a Vec of records bound for that table +pub static NUM_BATCH_THREADS: usize = 16; + +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] async fn handle_body( - client: ×tream_write::Client, + client: &Arc, body: &[u8], precision: ×tream_write::types::TimeUnit, ) -> Result<(), Error> { @@ -36,55 +49,110 @@ async fn handle_body( Ok(()) } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] async fn handle_multi_table_ingestion( - client: ×tream_write::Client, + client: &Arc, records: HashMap>, ) -> Result<(), Error> { // Ingestion for multi-measure schema type let database_name = std::env::var("database_name")?; + let database_name = Arc::new(database_name); - match database_exists(client, &database_name).await { - Ok(true) => (), - Ok(false) => { - if database_creation_enabled()? { - thread::sleep(time::Duration::from_secs(TIMESTREAM_API_WAIT_SECONDS)); - create_database(client, &database_name).await?; - } else { - return Err(anyhow!( - "Database {} does not exist and database creation is not enabled", - database_name - )); - } - } - Err(error) => return Err(anyhow!(error)), - } - - for (table_name, _) in records.iter() { - match table_exists(client, &database_name, table_name).await { + if let Ok(true) = std::env::var("enable_database_creation").map(env_var_to_bool) { + match database_exists(client, &database_name).await { Ok(true) => (), Ok(false) => { - if table_creation_enabled()? { + if database_creation_enabled()? { thread::sleep(time::Duration::from_secs(TIMESTREAM_API_WAIT_SECONDS)); - create_table(client, &database_name, table_name, get_table_config()?).await? + create_database(client, &database_name).await?; } else { return Err(anyhow!( - "Table {} does not exist and database creation is not enabled", - table_name + "Database {} does not exist and database creation is not enabled", + database_name )); } } - Err(error) => println!("error checking table exists: {:?}", error), + Err(error) => return Err(anyhow!(error)), } } - for (table_name, mut records) in records.into_iter() { - ingest_records(client, &database_name, &table_name, &mut records).await? + // Use a semaphore to limit the maximum number of threads used to process batches in parallel + let ingestion_semaphore = Arc::new(Semaphore::new(NUM_BATCH_THREADS)); + let mut batch_ingestion_futures = FuturesUnordered::new(); + + // Track total time taken to check existence of tables and ingest records + let ingestion_start = Instant::now(); + + // Ingest records for each table, in parallel + for (table_name, records) in records { + let permit = ingestion_semaphore + .clone() + .acquire_owned() + .await + .expect("Failed to get semaphore permit"); + + // Use Arc::clone to create a shallow clone of the client + let client_clone = Arc::clone(client); + let database_name_clone = Arc::clone(&database_name); + + // Create a future for ingesting to the current table + let future = task::spawn(async move { + if let Ok(true) = std::env::var("enable_table_creation").map(env_var_to_bool) { + let _ = + create_table_if_non_existent(&client_clone, &database_name_clone, &table_name) + .await; + } + + // Ingest the data to the table + let result = + ingest_records(client_clone, database_name_clone, table_name, records).await; + drop(permit); + result + }); + batch_ingestion_futures.push(future); + } + + while let Some(result) = batch_ingestion_futures.next().await { + // result will be Result> + // This means the nested Result needs to be checked + match result { + Ok(Ok(_)) => {} + Ok(Err(error)) => { + return Err(anyhow!(error)); + } + Err(error) => { + return Err(anyhow!(error)); + } + } + } + + trace!( + "Total asynchronous ingestion duration: {:?}", + ingestion_start.elapsed() + ); + Ok(()) +} + +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] +pub async fn create_table_if_non_existent( + client: &Arc, + database_name: &Arc, + table_name: &str, +) -> Result<(), Error> { + match table_exists(client, database_name, table_name).await { + Ok(true) => (), + Ok(false) => { + thread::sleep(time::Duration::from_secs(TIMESTREAM_API_WAIT_SECONDS)); + create_table(client, database_name, table_name, get_table_config()?).await? + } + Err(error) => info!("error checking table exists: {:?}", error), } Ok(()) } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn get_precision(event: &Value) -> Option<&str> { // Retrieves the optional "precision" query string parameter from a serde_json::Value @@ -110,8 +178,9 @@ pub fn get_precision(event: &Value) -> Option<&str> { None } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub async fn lambda_handler( - client: ×tream_write::Client, + client: &Arc, event: LambdaEvent, ) -> Result { // Handler for lambda runtime diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/line_protocol_parser.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/line_protocol_parser.rs index 24af9d66..b7bb912b 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/line_protocol_parser.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/line_protocol_parser.rs @@ -2,6 +2,7 @@ use crate::metric::{self, Metric}; use anyhow::{anyhow, Error}; use influxdb_line_protocol::{self, parse_lines, ParsedLine}; +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn parse_line_protocol(line_protocol: &str) -> Result, Error> { // Parses a string of line protocol to a vector of Metric structs, @@ -19,9 +20,11 @@ pub fn parse_line_protocol(line_protocol: &str) -> Result, Error> { } } } + Ok(output_metrics) } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn parsed_line_to_metric(parsed_line: ParsedLine) -> Result { // Converts an influxdb_line_protocol ParsedLine struct to a Metric struct. diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/main.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/main.rs index 56b7818d..0f7497c5 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/main.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/main.rs @@ -1,15 +1,45 @@ use influxdb_timestream_connector::{ lambda_handler, records_builder::validate_env_variables, timestream_utils::get_connection, }; -use lambda_runtime::{run, service_fn, tracing, Error, LambdaEvent}; +use lambda_runtime::{run, service_fn, Error, LambdaEvent}; use serde_json::Value; +use std::sync::Arc; +use tracing_subscriber::{ + filter::EnvFilter, + fmt::{self, format::FmtSpan}, +}; + +// The number of threads to use to chunk Vecs in parallel +// using rayon +// Lambda functions have a maximum of 1024 threads +pub static NUM_RAYON_THREADS: usize = 32; #[tokio::main] async fn main() -> Result<(), Error> { + // Configure logging + let env_filter = EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("lambda_runtime=warn,INFO")); + + fmt::fmt() + .with_env_filter(env_filter) + .with_level(true) + .with_span_events(FmtSpan::CLOSE) + .with_target(true) + .with_thread_names(true) + .with_level(true) + .compact() + .init(); + + // Set global maximum rayon threads + rayon::ThreadPoolBuilder::new() + .num_threads(NUM_RAYON_THREADS) + .build_global() + .unwrap(); + validate_env_variables()?; let region = std::env::var("region")?; let timestream_client = get_connection(®ion).await?; - tracing::init_default_subscriber(); + let timestream_client = Arc::new(timestream_client); run(service_fn(|event: LambdaEvent| { lambda_handler(×tream_client, event) })) diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs index 63244f8f..e59e056f 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs @@ -1,13 +1,14 @@ use crate::metric::Metric; use anyhow::{anyhow, Error}; use aws_sdk_timestreamwrite as timestream_write; -use std::collections::HashMap; +use std::{collections::HashMap, fmt::Debug}; mod multi_table_multi_measure_builder; const DIMENSION_PARTITION_KEY_TYPE: &str = "dimension"; const MEASURE_PARTITION_KEY_TYPE: &str = "measure"; +#[derive(Debug)] pub enum SchemaType { MultiTableMultiMeasure(String), } @@ -15,11 +16,12 @@ pub enum SchemaType { impl std::fmt::Display for SchemaType { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { - SchemaType::MultiTableMultiMeasure(v) => v.fmt(f), + SchemaType::MultiTableMultiMeasure(v) => std::fmt::Display::fmt(&v, f), } } } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn get_builder(schema: SchemaType) -> impl BuildRecords { // Currently only supported schema is multi-table multi-measure multi_table_multi_measure_builder::MultiTableMultiMeasureBuilder { @@ -27,6 +29,7 @@ pub fn get_builder(schema: SchemaType) -> impl BuildRecords { } } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn build_records( records_builder: &impl BuildRecords, metrics: &[Metric], @@ -35,6 +38,7 @@ pub fn build_records( records_builder.build_records(metrics, precision) } +#[derive(Debug)] pub struct TableConfig { pub mag_store_retention_period: i64, pub mem_store_retention_period: i64, @@ -44,6 +48,7 @@ pub struct TableConfig { pub custom_partition_key_dimension: Option, } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn get_table_config() -> Result { // Get the populated table_config struct @@ -110,6 +115,7 @@ pub fn get_table_config() -> Result { }) } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn table_creation_enabled() -> Result { // Convert the env var table_creation_enabled to bool @@ -121,9 +127,9 @@ pub fn table_creation_enabled() -> Result { } } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn database_creation_enabled() -> Result { // Convert the env var database_creation_enabled to bool - match std::env::var("enable_database_creation") { Ok(enabled) => Ok(env_var_to_bool(enabled)), Err(_) => Err(anyhow!( @@ -132,12 +138,14 @@ pub fn database_creation_enabled() -> Result { } } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn env_var_to_bool(env_var: String) -> bool { // Convert the env var to bool matches!(env_var.as_str(), "true" | "t" | "1") } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn validate_env_variables() -> Result<(), Error> { // Validate environment variables for all schema types @@ -217,7 +225,7 @@ pub fn validate_env_variables() -> Result<(), Error> { Ok(()) } -pub trait BuildRecords { +pub trait BuildRecords: Debug { fn build_records( &self, metrics: &[Metric], diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_table_multi_measure_builder.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_table_multi_measure_builder.rs index 14062683..5a746fef 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_table_multi_measure_builder.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_table_multi_measure_builder.rs @@ -11,6 +11,7 @@ pub struct MultiTableMultiMeasureBuilder { impl BuildRecords for MultiTableMultiMeasureBuilder { // trait implementation to support multi-measure multi-table schema with Timestream + #[tracing::instrument(skip_all, level = tracing::Level::TRACE)] fn build_records( &self, metrics: &[Metric], @@ -22,6 +23,13 @@ impl BuildRecords for MultiTableMultiMeasureBuilder { } } +impl std::fmt::Debug for MultiTableMultiMeasureBuilder { + fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(formatter, "{}", self.measure_name) + } +} + +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] fn validate_multi_measure_env_variables() -> Result<(), Error> { // Validate environment variables for multi-measure schema types @@ -34,6 +42,7 @@ fn validate_multi_measure_env_variables() -> Result<(), Error> { Ok(()) } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] fn build_multi_measure_records( metrics: &[Metric], measure_name: &str, @@ -56,6 +65,7 @@ fn build_multi_measure_records( Ok(multi_table_batch) } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn metric_to_timestream_record( measure_name: &str, metric: &Metric, @@ -99,6 +109,7 @@ pub fn metric_to_timestream_record( Ok(new_record) } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn get_timestream_measure_type( field_value: &FieldValue, ) -> Result { diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs index ab60790e..2db26c96 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs @@ -2,8 +2,19 @@ use super::records_builder::TableConfig; use anyhow::{anyhow, Error, Result}; use aws_sdk_timestreamwrite as timestream_write; use aws_types::region::Region; -use std::io::Write; - +use futures::stream::FuturesUnordered; +use futures::StreamExt; +use log::info; +use rayon::prelude::*; +use std::sync::Arc; +use tokio::sync::Semaphore; +use tokio::task; + +// The maximum number of threads to use for ingesting +// batches of records to Timestream in parallel +static NUM_TIMESTREAM_INGEST_THREADS: usize = 12; + +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub async fn get_connection( region: &str, ) -> Result { @@ -19,17 +30,18 @@ pub async fn get_connection( .expect("Failed to get the write client connection with Timestream"); tokio::task::spawn(reload.reload_task()); - println!("Initialized connection to Timestream in region {}", region); + info!("Initialized connection to Timestream in region {}", region); Ok(client) } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub async fn create_database( - client: ×tream_write::Client, + client: &Arc, database_name: &str, ) -> Result<(), timestream_write::Error> { // Create a new Timestream database - println!("Creating new database {}", database_name); + info!("Creating new database {}", database_name); client .create_database() .set_database_name(Some(database_name.to_owned())) @@ -39,15 +51,16 @@ pub async fn create_database( Ok(()) } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub async fn create_table( - client: ×tream_write::Client, + client: &Arc, database_name: &str, table_name: &str, table_config: TableConfig, ) -> Result<(), timestream_write::Error> { // Create a new Timestream table - println!( + info!( "Creating new table {} for database {}", table_name, database_name ); @@ -91,8 +104,9 @@ pub async fn create_table( Ok(()) } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub async fn table_exists( - client: ×tream_write::Client, + client: &Arc, database_name: &str, table_name: &str, ) -> Result { @@ -116,8 +130,9 @@ pub async fn table_exists( } } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub async fn database_exists( - client: ×tream_write::Client, + client: &Arc, database_name: &str, ) -> Result { // Check if database already exists @@ -139,45 +154,94 @@ pub async fn database_exists( } } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub async fn ingest_records( - client: ×tream_write::Client, - database_name: &str, - table_name: &str, - records: &mut [timestream_write::types::Record], + client: Arc, + database_name: Arc, + table_name: String, + records: Vec, ) -> Result<(), Error> { // Ingest records to Timestream in batches of 100 (Max supported Timestream batch size) + // in parallel let mut records_ingested: usize = 0; const MAX_TIMESTREAM_BATCH_SIZE: usize = 100; - let mut records_chunked: Vec> = records - .chunks(MAX_TIMESTREAM_BATCH_SIZE) + // Chunk records in parallel using rayon (par_chunks) + let records_chunked: Vec> = records + .par_chunks(MAX_TIMESTREAM_BATCH_SIZE) .map(|sub_records| sub_records.to_vec()) .collect(); - for chunk in records_chunked.iter_mut() { - records_ingested += chunk.len(); - match client - .write_records() - .database_name(database_name) - .table_name(table_name) - .set_records(Some(std::mem::take(chunk))) - .send() + + // Use a semaphore to limit the maximum number of threads used to ingest chunks in parallel + let ingestion_semaphore = Arc::new(Semaphore::new(NUM_TIMESTREAM_INGEST_THREADS)); + let mut ingestion_futures = FuturesUnordered::new(); + + // Ingest chunks in parallel + for chunk in records_chunked { + let permit = ingestion_semaphore + .clone() + .acquire_owned() .await - { - Ok(_) => { - println!("{} records ingested", records_ingested); - std::io::stdout().flush()?; + .expect("Failed to get semaphore permit"); + records_ingested += chunk.len(); + let client_clone = Arc::clone(&client); + let table_name_clone = table_name.clone(); + let database_name_clone = Arc::clone(&database_name).to_string(); + + let future = task::spawn(async move { + let result = + ingest_record_batch(client_clone, database_name_clone, table_name_clone, chunk) + .await; + drop(permit); + result + }); + + ingestion_futures.push(future); + } + + while let Some(result) = ingestion_futures.next().await { + // result will be Result> + match result { + Ok(Ok(_)) => {} + Ok(Err(error)) => { + return Err(anyhow!(error)); } Err(error) => { - println!("SdkError: {:?}", error.raw_response().unwrap()); return Err(anyhow!(error)); } } } - println!( + + info!( "{} records ingested total for table {} in database {}", records_ingested, table_name, database_name ); Ok(()) } + +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] +pub async fn ingest_record_batch( + client: Arc, + database_name: String, + table_name: String, + chunk: Vec, +) -> Result<(), Error> { + match client + .write_records() + .database_name(database_name) + .table_name(table_name) + .set_records(Some(chunk)) + .send() + .await + { + Ok(_) => {} + Err(error) => { + info!("SdkError: {:?}", error.raw_response().unwrap()); + return Err(anyhow!(error)); + } + }; + + Ok(()) +} diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml index ca371658..ec5888c1 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml +++ b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml @@ -94,6 +94,10 @@ Parameters: MinValue: 2 Default: 30000 Description: The maximum amount of time in milliseconds an API Gateway event will wait before timing out. + RustLog: + Type: String + Default: 'INFO' + Description: The log level to use for the Lambda function. Typical values are error, warn, info, debug, trace, and off. Use trace in order to log the execution time of each function. Defaults to INFO. LambdaTimeoutInSeconds: Type: Number MinValue: 3 @@ -544,6 +548,7 @@ Resources: enforce_custom_partition_key: !If [EnforceCustomPartitionKeyProvided, !Ref EnforceCustomPartitionKey, !Ref "AWS::NoValue"] mag_store_retention_period: !Ref MagStoreRetentionPeriod mem_store_retention_period: !Ref MemStoreRetentionPeriod + RUST_LOG: !Ref RustLog PackageType: Zip Timeout: !Ref LambdaTimeoutInSeconds MemorySize: !Ref LambdaMemorySize diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs b/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs index 7119a8f3..116f7aab 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs @@ -6,6 +6,7 @@ use lambda_runtime::{Context, LambdaEvent}; use rand::{distributions::uniform::SampleUniform, distributions::Alphanumeric, Rng}; use serde_json::{json, Value}; use std::collections::HashMap; +use std::sync::Arc; use std::{env, thread, time}; static DATABASE_NAME: &str = "influxdb_timestream_connector_integ_db"; @@ -99,6 +100,7 @@ async fn test_mtmm_basic() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -136,6 +138,7 @@ async fn test_mtmm_create_database() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -185,6 +188,7 @@ async fn test_mtmm_unusual_query_parameters() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -220,6 +224,7 @@ async fn test_mtmm_no_query_parameters() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -253,6 +258,7 @@ async fn test_mtmm_multiple_timestamps() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -287,6 +293,7 @@ async fn test_mtmm_many_tags_many_fields() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -334,6 +341,7 @@ async fn test_mtmm_float() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -369,6 +377,7 @@ async fn test_mtmm_string() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -404,6 +413,7 @@ async fn test_mtmm_bool() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -442,6 +452,7 @@ async fn test_mtmm_max_tag_length() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -481,6 +492,7 @@ async fn test_mtmm_beyond_max_tag_length() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -517,6 +529,7 @@ async fn test_mtmm_max_field_length() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -555,6 +568,7 @@ async fn test_mtmm_beyond_max_field_length() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -591,6 +605,7 @@ async fn test_mtmm_max_unique_field_keys() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -633,6 +648,7 @@ async fn test_mtmm_beyond_max_unique_field_keys() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -674,6 +690,7 @@ async fn test_mtmm_max_unique_tag_keys() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -716,6 +733,7 @@ async fn test_mtmm_beyond_max_unique_tag_keys() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -757,6 +775,7 @@ async fn test_mtmm_max_table_name_length() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = random_string(MAX_TIMESTREAM_TABLE_NAME_LENGTH); @@ -794,6 +813,7 @@ async fn test_mtmm_beyond_max_table_name_length() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = random_string(MAX_TIMESTREAM_TABLE_NAME_LENGTH + 1); @@ -827,6 +847,7 @@ async fn test_mtmm_nanosecond_precision() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -864,6 +885,7 @@ async fn test_mtmm_microsecond_precision() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -899,6 +921,7 @@ async fn test_mtmm_second_precision() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -934,6 +957,7 @@ async fn test_mtmm_no_precision() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -972,6 +996,7 @@ async fn test_mtmm_empty_point() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let point = String::new(); @@ -995,6 +1020,7 @@ pub async fn test_mtmm_small_timestamp() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -1030,6 +1056,7 @@ async fn test_mtmm_5_measurements() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let mut table_names_to_delete = Vec::::new(); let lp_measurement_name = String::from("readings"); @@ -1073,6 +1100,7 @@ async fn test_mtmm_100_measurements() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let mut table_names_to_delete = Vec::::new(); @@ -1118,6 +1146,7 @@ async fn test_mtmm_5000_batch() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); let mut lp_batch = String::new(); @@ -1166,6 +1195,7 @@ async fn test_mtmm_no_credentials() { .with_endpoint_discovery_enabled() .await .expect("Failed to get the write client connection with Timestream"); + let client = Arc::new(client); tokio::task::spawn(reload.reload_task()); let lp_measurement_name = String::from("readings"); @@ -1209,6 +1239,7 @@ async fn test_mtmm_incorrect_credentials() { .with_endpoint_discovery_enabled() .await .expect("Failed to get the write client connection with Timestream"); + let client = Arc::new(client); tokio::task::spawn(reload.reload_task()); let lp_measurement_name = String::from("readings"); @@ -1241,6 +1272,7 @@ async fn test_mtmm_custom_dimension_partition_key_optional_enforcement() -> Resu let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -1281,6 +1313,7 @@ async fn test_mtmm_custom_dimension_partition_key_required_enforcement_accepted( let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -1321,6 +1354,7 @@ async fn test_mtmm_custom_dimension_partition_key_required_enforcement_rejected( let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -1359,6 +1393,7 @@ async fn test_mtmm_custom_dimension_partition_key_no_dimension() -> Result<(), E let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -1397,6 +1432,7 @@ async fn test_mtmm_custom_dimension_partition_key_no_enforcement() -> Result<(), let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -1435,6 +1471,7 @@ async fn test_mtmm_custom_measure_partition_key() -> Result<(), Error> { let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -1474,6 +1511,7 @@ async fn test_mtmm_custom_measure_partition_key_with_dimension() -> Result<(), E let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); @@ -1514,6 +1552,7 @@ async fn test_mtmm_custom_measure_partition_key_with_enforcement() -> Result<(), let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); + let client = Arc::new(client); let lp_measurement_name = String::from("readings"); diff --git a/integrations/influxdb_connector/sample-influxdb-clients/go/line-protocol-client-demo.go b/integrations/influxdb_connector/sample-influxdb-clients/go/line-protocol-client-demo.go index 85ef1800..4d09b0df 100644 --- a/integrations/influxdb_connector/sample-influxdb-clients/go/line-protocol-client-demo.go +++ b/integrations/influxdb_connector/sample-influxdb-clients/go/line-protocol-client-demo.go @@ -35,7 +35,7 @@ var( func main() { flag.StringVar(®ion, "region", "us-east-1", "AWS region for InfluxDB Timestream Connector") flag.StringVar(&service, "service", "lambda", "Service value for SigV4 header") - flag.StringVar(&endpoint, "endpoint", "http://127.0.0.1:9000", "Endpoint for InfluxDB Timestream Connector") + flag.StringVar(&endpoint, "endpoint", "http://localhost:9000", "Endpoint for InfluxDB Timestream Connector") flag.StringVar(&dataset, "dataset", "../data/bird-migration.line", "Line protocol dataset being ingested") flag.StringVar(&precision, "precision", "ns", "Precision for line protocol: nanoseconds=ns, milliseconds=ms, microseconds=us, seconds=s") flag.Parse() From 4c518202dbec86c55759af96a723890c02a1ac83 Mon Sep 17 00:00:00 2001 From: Forest Vey Date: Thu, 31 Oct 2024 11:25:47 -0700 Subject: [PATCH 08/11] Add Single Table Mapping for InfluxDB Timestream Connector (#23) * Initial implementation for adding single-table mapping. Signed-off-by: forestmvey * Adding environment variables and updating documentation. Signed-off-by: forestmvey * Adding tests and revising single-table mapping. Signed-off-by: forestmvey * Adding single table example to README. Signed-off-by: forestmvey * Fixing measure-name using line protocol metric name for single table mapping. Signed-off-by: forestmvey * Fix typo in README. Signed-off-by: forestmvey * Fixing documentation typos and test setting wrong environment variable. Signed-off-by: forestmvey * Fixing table for single table multi measure records example in README. Signed-off-by: forestmvey * Fix invalid line protocol examples with additional comma separating the timestamp. Signed-off-by: forestmvey --------- Signed-off-by: forestmvey --- .../influxdb-timestream-connector/Cargo.toml | 3 +- .../influxdb-timestream-connector/README.md | 49 +++- .../influxdb-timestream-connector/src/lib.rs | 20 +- .../src/records_builder.rs | 149 ++++-------- ...re_builder.rs => multi_measure_builder.rs} | 84 +++++-- .../src/records_builder/tests.rs | 207 ++++++++++++++-- .../src/timestream_utils.rs | 81 ++++++- .../template.yml | 12 +- .../tests/integration_test.rs | 226 +++++++++++++++--- 9 files changed, 633 insertions(+), 198 deletions(-) rename integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/{multi_table_multi_measure_builder.rs => multi_measure_builder.rs} (58%) diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml index 6b827e4d..c9d5b9e8 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml +++ b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml @@ -32,6 +32,8 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } [package.metadata.lambda.env] region = "us-east-1" database_name = "influxdb-line-protocol" +table_mapping = "single-table" +single_table_name = "influxdb-measures" measure_name_for_multi_measure_records = "influxdb-measure" enable_database_creation = "true" enable_table_creation = "true" @@ -39,4 +41,3 @@ enable_mag_store_writes = "true" mag_store_retention_period = "8000" mem_store_retention_period = "12" local_invocation = "true" - diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/README.md b/integrations/influxdb_connector/influxdb-timestream-connector/README.md index 164b8229..294a5c96 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/README.md +++ b/integrations/influxdb_connector/influxdb-timestream-connector/README.md @@ -10,7 +10,37 @@ The following diagram shows a high-level overview of the connector's architectur -## Table Schema +## Table Mapping + +### Single-Table Multi-Measure + +The following table shows how the connector maps line protocol elements to Timestream for LiveAnalytics record attributes when table mapping is set to single table. + +| Line Protocol Element | Timestream Record Attribute | +|-----------------------|---------------------------| +| Timestamp | Time | +| Tags | Dimensions | +| Fields | Measures | +| Measurements | Measure names | + +Single table mapping ingests all line protocol points ingested through the InfluxDB Timestream Connector to the table defined with the `single_table_name` environment variable. The `measure_name` in each Timestream record is derived from the line protocol measurement. + +The following example shows the translation of two line protocol points into a Timestream for LiveAnalytics table, using Timestamps with second precision and a `single_table_name` Lambda environment variable configured to `influxdb-measures`: + +#### Line Protocol Points + +``` +cpu_load_short,host=server01,region=us-west value=0.64,average=1.24 1725059274 +weather,location=us-midwest,season=summer temperature=82.0,humidity=71.0 1706480990 +``` + +#### Resulting influxdb-measures Timestream for LiveAnalytics Table + +| host | region | location | season | measure_name | time | value | average | temperature | humidity | +|----------|---------|---------------------|------------------|-------------------------------|-------|---------|-------------|----------| +| server01 | us-west | | | cpu_load_short | 2024-08-30 23:07:54.000000000 | 0.64 | 1.24 | | | +| | | us-midwest | summer | weather | 2024-01-22 26:07:33.000000000 | | | 82.0 | 71.0 | + ### Multi-Table Multi-Measure @@ -25,12 +55,13 @@ The following table shows how the connector maps line protocol elements to Times A Timestream record's `measure_name` field is not derived from any element of ingested line protocol. Due to the multi-measure record translation, the connector sets the `measure_name` for each multi-measure record to the value of a Lambda environment variable. When [deployed as part of a CloudFormation stack](#aws-cloudformation-deployment), this can be customized by overriding the `MeasureNameForMultiMeasureRecords` parameter. When [deployed locally](#local-deployment), this can be customized by setting the `measure_name_for_multi_measure_records` environment variable. -The following example shows the translation of a single line protocol point into a Timestream for LiveAnalytics table, using a Timestamp with second precision and a Lambda environment variable configured to `influxdb-measure`: +The following example shows the translation of two line protocol points into two Timestream for LiveAnalytics tables, using Timestamps with second precision and a Lambda environment variable configured to `influxdb-measure`: -#### Line Protocol Point +#### Line Protocol Points ``` -cpu_load_short,host=server01,region=us-west value=0.64,average=1.24, 1725059274 +cpu_load_short,host=server01,region=us-west value=0.64,average=1.24 1725059274 +weather,location=us-midwest,season=summer temperature=82.0,humidity=71.0 1706480990 ``` #### Resulting cpu_load_short Timestream for LiveAnalytics Table @@ -39,6 +70,12 @@ cpu_load_short,host=server01,region=us-west value=0.64,average=1.24, 1725059274 |----------|---------|------------------|-------------------------------|-------|---------| | server01 | us-west | influxdb-measure | 2024-08-30 23:07:54.000000000 | 0.64 | 1.24 | +#### Resulting weather Timestream for LiveAnalytics Table + +| location | season | measure_name | time | temperature | humidity | +|------------|---------|------------------|-------------------------------|-------------|----------| +| us-midwest | summer | influxdb-measure | 2024-01-22 26:0733.000000000 | 82.0 | 71.0 | + ## Deployment Options ### AWS CloudFormation Deployment @@ -72,6 +109,8 @@ The following parameters are available when deploying the connector as part of a | `RestApiGatewayStageName` | The name to use for the REST API Gateway stage. | `dev` | | `RestApiGatewayTimeoutInMillis` | The maximum number of milliseconds a REST API Gateway event will wait before timing out. | `30000` | | `RustLog` | The log level to use for the Lambda function. Typical values are error, warn, info, debug, trace, and off. Use trace in order to log the execution time of each function. | `INFO` | +| `SingleTableName` | Determines the table name for ingestion when table mapping is type single-table. | `influxdb-measures` | +| `TableMapping` | Determines whether to ingest all records to a single table or to multiple tables. | `single-table` | | `WriteThrottlingBurstLimit` | The number of burst requests per second that the REST API Gateway permits. | `1200` | ##### SAM Deployment Steps @@ -143,6 +182,8 @@ The connector can be run locally using [Cargo Lambda](https://www.cargo-lambda.i - `region` string: the AWS region to use. Defaults to `us-east-1`. - `database_name` string: the Timestream for LiveAnalytics database name to use. Defaults to `influxdb-line-protocol`. - `measure_name_for_multi_measure_records` string: the value to use in records as the measure name. Defaults to `influxdb-measure`. + - `table_mapping` string: determines whether to ingest all data to a single table or multiple tables. + - `single_table_name` string: when table mapping is set to `single-table`, this value determines the table name. - `enable_database_creation` bool: whether to create a database if the `database_name` database does not already exist in Timestream for LiveAnalytics. Defaults to `true`. - `enable_table_creation` bool: whether to create new tables if they don't already exist. Defaults to `true`. - `enable_mag_store_writes` bool: if `enable_table_creation` is `true`, whether to enable mag store writes. Defaults to `true`. diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs index 0266ac6c..b42a1eb5 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/lib.rs @@ -39,18 +39,26 @@ async fn handle_body( let line_protocol = str::from_utf8(body).unwrap(); let metric_data = parse_line_protocol(line_protocol)?; - let multi_measure_builder = get_builder(SchemaType::MultiTableMultiMeasure(std::env::var( - "measure_name_for_multi_measure_records", - )?)); - // Only currently supports multi-measure multi-table + let multi_measure_builder = match std::env::var("table_mapping")?.to_lowercase().as_str() { + "multi-table" => get_builder( + SchemaType::MultiTableMultiMeasure, + std::env::var("measure_name_for_multi_measure_records")?, + ), + _ => get_builder( + SchemaType::SingleTableMultiMeasure, + std::env::var("single_table_name")?, + ), + }; + + // Only currently supports multi-measure let multi_table_batch = build_records(&multi_measure_builder, &metric_data, precision)?; - handle_multi_table_ingestion(client, multi_table_batch).await?; + handle_ingestion(client, multi_table_batch).await?; Ok(()) } #[tracing::instrument(skip_all, level = tracing::Level::TRACE)] -async fn handle_multi_table_ingestion( +async fn handle_ingestion( client: &Arc, records: HashMap>, ) -> Result<(), Error> { diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs index e59e056f..545ee4ea 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs @@ -1,31 +1,32 @@ use crate::metric::Metric; +use crate::timestream_utils::{DIMENSION_PARTITION_KEY_TYPE, MEASURE_PARTITION_KEY_TYPE}; use anyhow::{anyhow, Error}; -use aws_sdk_timestreamwrite as timestream_write; +use aws_sdk_timestreamwrite::types as timestream_types; use std::{collections::HashMap, fmt::Debug}; -mod multi_table_multi_measure_builder; - -const DIMENSION_PARTITION_KEY_TYPE: &str = "dimension"; -const MEASURE_PARTITION_KEY_TYPE: &str = "measure"; +mod multi_measure_builder; #[derive(Debug)] pub enum SchemaType { - MultiTableMultiMeasure(String), -} - -impl std::fmt::Display for SchemaType { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - SchemaType::MultiTableMultiMeasure(v) => std::fmt::Display::fmt(&v, f), - } - } + MultiTableMultiMeasure, + SingleTableMultiMeasure, } #[tracing::instrument(skip_all, level = tracing::Level::TRACE)] -pub fn get_builder(schema: SchemaType) -> impl BuildRecords { - // Currently only supported schema is multi-table multi-measure - multi_table_multi_measure_builder::MultiTableMultiMeasureBuilder { - measure_name: schema.to_string(), +pub fn get_builder(schema: SchemaType, measure_name: String) -> impl BuildRecords { + match schema { + SchemaType::SingleTableMultiMeasure => { + return multi_measure_builder::MultiMeasureBuilder { + measure_name: None, + schema_type: SchemaType::SingleTableMultiMeasure, + } + } + SchemaType::MultiTableMultiMeasure => { + return multi_measure_builder::MultiMeasureBuilder { + measure_name: Some(measure_name.to_string()), + schema_type: SchemaType::MultiTableMultiMeasure, + } + } } } @@ -33,88 +34,11 @@ pub fn get_builder(schema: SchemaType) -> impl BuildRecords { pub fn build_records( records_builder: &impl BuildRecords, metrics: &[Metric], - precision: ×tream_write::types::TimeUnit, -) -> Result>, Error> { + precision: ×tream_types::TimeUnit, +) -> Result>, Error> { records_builder.build_records(metrics, precision) } -#[derive(Debug)] -pub struct TableConfig { - pub mag_store_retention_period: i64, - pub mem_store_retention_period: i64, - pub enable_mag_store_writes: bool, - pub enforce_custom_partition_key: Option, - pub custom_partition_key_type: Option, - pub custom_partition_key_dimension: Option, -} - -#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] -pub fn get_table_config() -> Result { - // Get the populated table_config struct - - let custom_partition_key_type = match std::env::var("custom_partition_key_type") { - Ok(custom_partition_key_type_value) => { - match custom_partition_key_type_value.to_lowercase().as_str() { - DIMENSION_PARTITION_KEY_TYPE => { - Some(timestream_write::types::PartitionKeyType::Dimension) - } - MEASURE_PARTITION_KEY_TYPE => { - Some(timestream_write::types::PartitionKeyType::Measure) - } - _ => None, - } - } - _ => None, - }; - - // If custom_partition_key_type is "dimension", then enforce_custom_partition_key is required (true or false). - // If custom_partition_key_type is "measure", then this will ignore enforce_custom_partition_key. - // The SDK will return an error if custom_partition_key_type is "measure" and any value is specified for - // enforce_custom_partition_key - let enforce_custom_partition_key = match custom_partition_key_type { - Some(timestream_write::types::PartitionKeyType::Dimension) => { - // enforce_custom_partition_key value (true or false) is required if custom_partition_key_type is PartitionKeyType::Dimension - match std::env::var("enforce_custom_partition_key")? - .to_lowercase() - .as_str() - { - "true" | "t" | "1" => { - Some(timestream_write::types::PartitionKeyEnforcementLevel::Required) - } - "false" | "f" | "0" => { - Some(timestream_write::types::PartitionKeyEnforcementLevel::Optional) - } - _ => None, - } - } - _ => None, - }; - - // If custom_partition_key_type is "dimension", then custom_partition_key_dimension is required. - // The SDK will return an error if custom_partition_key_type is "measure" and - // any value is specified for custom_partition_key_dimension - let custom_partition_key_dimension = match custom_partition_key_type { - Some(timestream_write::types::PartitionKeyType::Dimension) => { - Some(std::env::var("custom_partition_key_dimension")?) - } - _ => None, - }; - - Ok(TableConfig { - mag_store_retention_period: std::env::var("mag_store_retention_period")?.parse()?, - mem_store_retention_period: std::env::var("mem_store_retention_period")?.parse()?, - enable_mag_store_writes: matches!( - std::env::var("enable_mag_store_writes")? - .to_lowercase() - .as_str(), - "true" | "t" | "1" - ), - enforce_custom_partition_key, - custom_partition_key_type, - custom_partition_key_dimension, - }) -} - #[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub fn table_creation_enabled() -> Result { // Convert the env var table_creation_enabled to bool @@ -186,6 +110,33 @@ pub fn validate_env_variables() -> Result<(), Error> { } } + // Validate environment variables for table mapping + match std::env::var("table_mapping") { + Ok(table_mapping) => match table_mapping.as_str() { + "single-table" => { + if std::env::var("single_table_name").is_err() { + return Err(anyhow!( + "single_table_name environment variable is not defined" + )); + } + } + "multi-table" => { + if std::env::var("measure_name_for_multi_measure_records").is_err() { + return Err(anyhow!( + "measure_name_for_multi_measure_records environment variable is not defined" + )); + } + } + table_mapping => { + return Err(anyhow!( + "{:?} is an invalid value for the table_mapping environment variable", + table_mapping + )) + } + }, + Err(_) => return Err(anyhow!("table_mapping environment variable is not defined")), + } + // Customer-defined partition key environment variables let custom_partition_key_type = std::env::var("custom_partition_key_type"); @@ -229,8 +180,8 @@ pub trait BuildRecords: Debug { fn build_records( &self, metrics: &[Metric], - precision: ×tream_write::types::TimeUnit, - ) -> Result>, Error>; + precision: ×tream_types::TimeUnit, + ) -> Result>, Error>; } #[cfg(test)] diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_table_multi_measure_builder.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_measure_builder.rs similarity index 58% rename from integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_table_multi_measure_builder.rs rename to integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_measure_builder.rs index 5a746fef..56ae2464 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_table_multi_measure_builder.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/multi_measure_builder.rs @@ -1,15 +1,19 @@ use super::{validate_env_variables, BuildRecords}; -use crate::metric::{FieldValue, Metric}; -use anyhow::{anyhow, Error, Result}; +use crate::{ + metric::{FieldValue, Metric}, + SchemaType, +}; +use anyhow::{Error, Result}; use aws_sdk_timestreamwrite as timestream_write; use std::collections::HashMap; -pub struct MultiTableMultiMeasureBuilder { - pub measure_name: String, +pub struct MultiMeasureBuilder { + pub measure_name: Option, + pub schema_type: SchemaType, } -impl BuildRecords for MultiTableMultiMeasureBuilder { - // trait implementation to support multi-measure multi-table schema with Timestream +impl BuildRecords for MultiMeasureBuilder { + // trait implementation to support multi-measure records Timestream #[tracing::instrument(skip_all, level = tracing::Level::TRACE)] fn build_records( @@ -18,51 +22,81 @@ impl BuildRecords for MultiTableMultiMeasureBuilder { precision: ×tream_write::types::TimeUnit, ) -> Result>, Error> { validate_env_variables()?; - validate_multi_measure_env_variables()?; - build_multi_measure_records(metrics, &self.measure_name, precision) + match self.schema_type { + SchemaType::SingleTableMultiMeasure => { + return build_single_table_multi_measure_records(metrics, precision) + } + SchemaType::MultiTableMultiMeasure => { + return build_multi_table_multi_measure_records( + metrics, + self.measure_name.as_deref(), + precision, + ) + } + } } } -impl std::fmt::Debug for MultiTableMultiMeasureBuilder { +impl std::fmt::Debug for MultiMeasureBuilder { fn fmt(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(formatter, "{}", self.measure_name) + write!( + formatter, + "{}", + self.measure_name + .as_deref() + .expect("Failed to unwrap") + .to_owned() + ) } } #[tracing::instrument(skip_all, level = tracing::Level::TRACE)] -fn validate_multi_measure_env_variables() -> Result<(), Error> { - // Validate environment variables for multi-measure schema types +fn build_single_table_multi_measure_records( + metrics: &[Metric], + precision: ×tream_write::types::TimeUnit, +) -> Result>, Error> { + // Builds multi-measure records hashmap to be ingested to one table - if std::env::var("measure_name_for_multi_measure_records").is_err() { - return Err(anyhow!( - "measure_name_for_multi_measure_records environment variable is not defined" - )); + let mut records_batch: HashMap> = + HashMap::new(); + let table_name = std::env::var("single_table_name")?; + for metric in metrics.iter() { + let new_record = metric_to_timestream_record(metric.name(), metric, precision)?; + if let Some(record_vec) = records_batch.get_mut(&table_name) { + record_vec.push(new_record); + } else { + records_batch.insert(table_name.to_string(), vec![new_record]); + } } - Ok(()) + Ok(records_batch) } #[tracing::instrument(skip_all, level = tracing::Level::TRACE)] -fn build_multi_measure_records( +fn build_multi_table_multi_measure_records( metrics: &[Metric], - measure_name: &str, + measure_name: Option<&str>, precision: ×tream_write::types::TimeUnit, ) -> Result>, Error> { - // Builds multi-measure multi-table records hashmap + // Builds multi-measure records hashmap to be ingested to multiple tables - let mut multi_table_batch: HashMap> = + let mut records_batch: HashMap> = HashMap::new(); for metric in metrics.iter() { - let new_record = metric_to_timestream_record(measure_name, metric, precision)?; + let new_record = metric_to_timestream_record( + measure_name.expect("Failed to unwrap"), + metric, + precision, + )?; let table_name = metric.name(); - if let Some(record_vec) = multi_table_batch.get_mut(table_name) { + if let Some(record_vec) = records_batch.get_mut(table_name) { record_vec.push(new_record); } else { - multi_table_batch.insert(table_name.to_string(), vec![new_record]); + records_batch.insert(table_name.to_string(), vec![new_record]); } } - Ok(multi_table_batch) + Ok(records_batch) } #[tracing::instrument(skip_all, level = tracing::Level::TRACE)] diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/tests.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/tests.rs index 9a9d0015..1991df69 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/tests.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder/tests.rs @@ -9,10 +9,13 @@ fn test_mtmm_single_record() -> Result<(), Error> { // Single measure for multi-measure record setup_minimal_env_vars(); - setup_multi_measure_env_vars(); - let multi_table_multi_measure_schema = - super::SchemaType::MultiTableMultiMeasure(String::from("influxdb-connector-measure")); - let multi_table_multi_measure_builder = super::get_builder(multi_table_multi_measure_schema); + setup_multi_table_multi_measure_env_vars(); + setup_table_mapping_env_variables(super::SchemaType::MultiTableMultiMeasure); + let multi_table_multi_measure_schema = super::SchemaType::MultiTableMultiMeasure; + let multi_table_multi_measure_builder = super::get_builder( + multi_table_multi_measure_schema, + String::from("influxdb-connector-measure"), + ); let metrics = [Metric::new( "readings".to_string(), vec![(String::from("goal"), String::from("baseline"))].into(), @@ -65,10 +68,13 @@ fn test_mtmm_single_destination() -> Result<(), Error> { // Dataset all going to same table setup_minimal_env_vars(); - setup_multi_measure_env_vars(); - let multi_table_multi_measure_schema = - super::SchemaType::MultiTableMultiMeasure(String::from("influxdb-connector-measure")); - let multi_table_multi_measure_builder = super::get_builder(multi_table_multi_measure_schema); + setup_multi_table_multi_measure_env_vars(); + setup_table_mapping_env_variables(super::SchemaType::MultiTableMultiMeasure); + let multi_table_multi_measure_schema = super::SchemaType::MultiTableMultiMeasure; + let multi_table_multi_measure_builder = super::get_builder( + multi_table_multi_measure_schema, + String::from("influxdb-connector-measure"), + ); let metrics = [ Metric::new( "readings".to_string(), @@ -151,10 +157,13 @@ fn test_mtmm_multi_record() -> Result<(), Error> { // Dataset going to multiple table destinations setup_minimal_env_vars(); - setup_multi_measure_env_vars(); - let multi_table_multi_measure_schema = - super::SchemaType::MultiTableMultiMeasure(String::from("influxdb-connector-measure")); - let multi_table_multi_measure_builder = super::get_builder(multi_table_multi_measure_schema); + setup_multi_table_multi_measure_env_vars(); + setup_table_mapping_env_variables(super::SchemaType::MultiTableMultiMeasure); + let multi_table_multi_measure_schema = super::SchemaType::MultiTableMultiMeasure; + let multi_table_multi_measure_builder = super::get_builder( + multi_table_multi_measure_schema, + String::from("influxdb-connector-measure"), + ); let metrics = [ Metric::new( "readings".to_string(), @@ -238,10 +247,13 @@ fn test_mtmm_empty_dimensions() -> Result<(), Error> { // Dataset with empty dimensions setup_minimal_env_vars(); - setup_multi_measure_env_vars(); - let multi_table_multi_measure_schema = - super::SchemaType::MultiTableMultiMeasure(String::from("influxdb-connector-measure")); - let multi_table_multi_measure_builder = super::get_builder(multi_table_multi_measure_schema); + setup_multi_table_multi_measure_env_vars(); + setup_table_mapping_env_variables(super::SchemaType::MultiTableMultiMeasure); + let multi_table_multi_measure_schema = super::SchemaType::MultiTableMultiMeasure; + let multi_table_multi_measure_builder = super::get_builder( + multi_table_multi_measure_schema, + String::from("influxdb-connector-measure"), + ); let metrics = [Metric::new( "readings".to_string(), None, @@ -285,10 +297,13 @@ fn test_mtmm_varying_timestamp_records() -> Result<(), Error> { // Varying timestamp parsing setup_minimal_env_vars(); - setup_multi_measure_env_vars(); - let multi_table_multi_measure_schema = - super::SchemaType::MultiTableMultiMeasure(String::from("influxdb-connector-measure")); - let multi_table_multi_measure_builder = super::get_builder(multi_table_multi_measure_schema); + setup_multi_table_multi_measure_env_vars(); + setup_table_mapping_env_variables(super::SchemaType::MultiTableMultiMeasure); + let multi_table_multi_measure_schema = super::SchemaType::MultiTableMultiMeasure; + let multi_table_multi_measure_builder = super::get_builder( + multi_table_multi_measure_schema, + String::from("influxdb-connector-measure"), + ); let metrics = [ Metric::new( "readings".to_string(), @@ -378,10 +393,160 @@ fn test_mtmm_varying_timestamp_records() -> Result<(), Error> { Ok(()) } -fn setup_multi_measure_env_vars() { +#[test] +fn test_stmm_single_record() -> Result<(), Error> { + // Single measure for multi-measure record + + setup_minimal_env_vars(); + setup_table_mapping_env_variables(super::SchemaType::SingleTableMultiMeasure); + let single_table_multi_measure_builder = super::get_builder( + super::SchemaType::SingleTableMultiMeasure, + String::from("influxdb-connector-measure"), + ); + let metrics = [Metric::new( + "readings".to_string(), + vec![(String::from("goal"), String::from("baseline"))].into(), + vec![(String::from("incline"), FieldValue::I64(125))], + 1577836800000, + )]; + + let records = build_records( + &single_table_multi_measure_builder, + &metrics, + ×tream_write::types::TimeUnit::Nanoseconds, + )?; + assert_eq!(records.len(), 1); + // Table name should align with environment variable + let first_record = records + .get("influxdb-measures") + .expect("Failed to unwrap") + .first() + .expect("Failed to unwrap"); + assert_eq!(first_record.time, Some(String::from("1577836800000"))); + + assert_eq!(first_record.measure_name(), Some("readings")); + assert_eq!( + first_record.measure_value_type(), + Some(×tream_write::types::MeasureValueType::Multi) + ); + assert!(first_record.measure_values().contains( + ×tream_write::types::MeasureValue::builder() + .name(String::from("incline")) + .value(String::from("125")) + .r#type(timestream_write::types::MeasureValueType::Bigint) + .build() + .expect("Failed to build measure") + )); + assert!(first_record.dimensions().contains( + ×tream_write::types::Dimension::builder() + .name(String::from("goal")) + .value(String::from("baseline")) + .build() + .expect("Failed to build dimension") + )); + + Ok(()) +} + +#[test] +fn test_stmm_multi_record() -> Result<(), Error> { + // Dataset with differing metric names going to the same table + + setup_minimal_env_vars(); + setup_table_mapping_env_variables(super::SchemaType::SingleTableMultiMeasure); + let single_table_multi_measure_builder = super::get_builder( + super::SchemaType::SingleTableMultiMeasure, + String::from("influxdb-connector-measure"), + ); + let metrics = [ + Metric::new( + "readings".to_string(), + vec![(String::from("goal"), String::from("baseline"))].into(), + vec![(String::from("incline"), FieldValue::I64(125))], + 1577836800000, + ), + Metric::new( + "velocity".to_string(), + vec![(String::from("goal"), String::from("baseline"))].into(), + vec![(String::from("km/h"), FieldValue::F64(4.6))], + 1577836911132, + ), + ]; + + let records = build_records( + &single_table_multi_measure_builder, + &metrics, + ×tream_write::types::TimeUnit::Nanoseconds, + )?; + // All items should only be going to one table + assert_eq!(records.len(), 1); + // Table name should align with environment variable + let records_vec = records.get("influxdb-measures").expect("failed to unwrap"); + let readings = records_vec.first().expect("Failed to unwrap"); + let velocity = records_vec.get(1).expect("Failed to unwrap"); + assert_eq!(readings.time, Some(String::from("1577836800000"))); + assert_eq!(velocity.time, Some(String::from("1577836911132"))); + + assert_eq!(readings.measure_name(), Some("readings")); + assert_eq!(velocity.measure_name(), Some("velocity")); + assert_eq!( + readings.measure_value_type(), + Some(×tream_write::types::MeasureValueType::Multi) + ); + assert_eq!( + velocity.measure_value_type(), + Some(×tream_write::types::MeasureValueType::Multi) + ); + assert!(readings.measure_values().contains( + ×tream_write::types::MeasureValue::builder() + .name(String::from("incline")) + .value(String::from("125")) + .r#type(timestream_write::types::MeasureValueType::Bigint) + .build() + .expect("Failed to build measure") + )); + assert!(readings.dimensions().contains( + ×tream_write::types::Dimension::builder() + .name(String::from("goal")) + .value(String::from("baseline")) + .build() + .expect("Failed to build dimension") + )); + assert!(velocity.measure_values().contains( + ×tream_write::types::MeasureValue::builder() + .name(String::from("km/h")) + .value(String::from("4.6")) + .r#type(timestream_write::types::MeasureValueType::Double) + .build() + .expect("Failed to build measure") + )); + assert!(velocity.dimensions().contains( + ×tream_write::types::Dimension::builder() + .name(String::from("goal")) + .value(String::from("baseline")) + .build() + .expect("Failed to build dimension") + )); + + Ok(()) +} + +fn setup_multi_table_multi_measure_env_vars() { env::set_var("measure_name_for_multi_measure_records", "influxdb-measure"); } +fn setup_table_mapping_env_variables(schema_type: super::SchemaType) { + match schema_type { + super::SchemaType::MultiTableMultiMeasure => { + env::set_var("table_mapping", "multi-table"); + } + super::SchemaType::SingleTableMultiMeasure => { + env::set_var("table_mapping", "single-table"); + env::set_var("single_table_name", "influxdb-measures"); + } + } +} + fn setup_minimal_env_vars() { env::set_var("enable_table_creation", "false"); env::set_var("region", "us-west-2"); diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs index 2db26c96..d5bbca34 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/timestream_utils.rs @@ -1,4 +1,3 @@ -use super::records_builder::TableConfig; use anyhow::{anyhow, Error, Result}; use aws_sdk_timestreamwrite as timestream_write; use aws_types::region::Region; @@ -14,6 +13,19 @@ use tokio::task; // batches of records to Timestream in parallel static NUM_TIMESTREAM_INGEST_THREADS: usize = 12; +pub const DIMENSION_PARTITION_KEY_TYPE: &str = "dimension"; +pub const MEASURE_PARTITION_KEY_TYPE: &str = "measure"; + +#[derive(Debug)] +pub struct TableConfig { + pub mag_store_retention_period: i64, + pub mem_store_retention_period: i64, + pub enable_mag_store_writes: bool, + pub enforce_custom_partition_key: Option, + pub custom_partition_key_type: Option, + pub custom_partition_key_dimension: Option, +} + #[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub async fn get_connection( region: &str, @@ -154,6 +166,73 @@ pub async fn database_exists( } } +#[tracing::instrument(skip_all, level = tracing::Level::TRACE)] +pub fn get_table_config() -> Result { + // Get the populated table_config struct + + let custom_partition_key_type = match std::env::var("custom_partition_key_type") { + Ok(custom_partition_key_type_value) => { + match custom_partition_key_type_value.to_lowercase().as_str() { + DIMENSION_PARTITION_KEY_TYPE => { + Some(timestream_write::types::PartitionKeyType::Dimension) + } + MEASURE_PARTITION_KEY_TYPE => { + Some(timestream_write::types::PartitionKeyType::Measure) + } + _ => None, + } + } + _ => None, + }; + + // If custom_partition_key_type is "dimension", then enforce_custom_partition_key is required (true or false). + // If custom_partition_key_type is "measure", then this will ignore enforce_custom_partition_key. + // The SDK will return an error if custom_partition_key_type is "measure" and any value is specified for + // enforce_custom_partition_key + let enforce_custom_partition_key = match custom_partition_key_type { + Some(timestream_write::types::PartitionKeyType::Dimension) => { + // enforce_custom_partition_key value (true or false) is required if custom_partition_key_type is PartitionKeyType::Dimension + match std::env::var("enforce_custom_partition_key")? + .to_lowercase() + .as_str() + { + "true" | "t" | "1" => { + Some(timestream_write::types::PartitionKeyEnforcementLevel::Required) + } + "false" | "f" | "0" => { + Some(timestream_write::types::PartitionKeyEnforcementLevel::Optional) + } + _ => None, + } + } + _ => None, + }; + + // If custom_partition_key_type is "dimension", then custom_partition_key_dimension is required. + // The SDK will return an error if custom_partition_key_type is "measure" and + // any value is specified for custom_partition_key_dimension + let custom_partition_key_dimension = match custom_partition_key_type { + Some(timestream_write::types::PartitionKeyType::Dimension) => { + Some(std::env::var("custom_partition_key_dimension")?) + } + _ => None, + }; + + Ok(TableConfig { + mag_store_retention_period: std::env::var("mag_store_retention_period")?.parse()?, + mem_store_retention_period: std::env::var("mem_store_retention_period")?.parse()?, + enable_mag_store_writes: matches!( + std::env::var("enable_mag_store_writes")? + .to_lowercase() + .as_str(), + "true" | "t" | "1" + ), + enforce_custom_partition_key, + custom_partition_key_type, + custom_partition_key_dimension, + }) +} + #[tracing::instrument(skip_all, level = tracing::Level::TRACE)] pub async fn ingest_records( client: Arc, diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml index ec5888c1..3482d33b 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml +++ b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml @@ -32,6 +32,14 @@ Parameters: Type: String Default: influxdb-measure Description: The value to use in records as the measure name. + TableMapping: + Type: String + Default: single-table + Description: Maps records ingested to a single table or multiple tables. + SingleTableName: + Type: String + Default: influxdb-measures + Description: Name of the table when table_mapping is set to single-table. EnableAsyncInvocation: Type: String AllowedValues: @@ -359,7 +367,6 @@ Resources: AsyncRestApiGatewayStage: Type: AWS::ApiGateway::Stage Condition: IsAsyncEnabled - DependsOn: AsyncRestApiGatewayDeployment Properties: StageName: !Ref RestApiGatewayStageName RestApiId: !Ref RestApiGateway @@ -381,7 +388,6 @@ Resources: SyncRestApiGatewayStage: Type: AWS::ApiGateway::Stage Condition: IsSyncEnabled - DependsOn: SyncRestApiGatewayDeployment Properties: StageName: !Ref RestApiGatewayStageName RestApiId: !Ref RestApiGateway @@ -541,6 +547,8 @@ Resources: custom_partition_key_dimension: !If [CustomPartitionKeyDimensionProvided, !Ref CustomPartitionKeyDimension, !Ref "AWS::NoValue"] database_name: !Ref DatabaseName measure_name_for_multi_measure_records: !Ref MeasureNameForMultiMeasureRecords + table_mapping: !Ref TableMapping + single_table_name: !Ref SingleTableName region: !Ref AWS::Region enable_database_creation: !Ref EnableDatabaseCreation enable_table_creation: !Ref EnableTableCreation diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs b/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs index 116f7aab..5c466511 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/tests/integration_test.rs @@ -2,6 +2,7 @@ use anyhow::Error; use aws_credential_types::Credentials; use aws_sdk_timestreamwrite as timestream_write; use aws_types::region::Region; +use influxdb_timestream_connector::records_builder::SchemaType; use lambda_runtime::{Context, LambdaEvent}; use rand::{distributions::uniform::SampleUniform, distributions::Alphanumeric, Rng}; use serde_json::{json, Value}; @@ -57,7 +58,19 @@ impl CleanupBatch { } } -fn set_environment_variables() { +fn set_table_mapping_env_variables(schema_type: SchemaType) { + match schema_type { + SchemaType::MultiTableMultiMeasure => { + env::set_var("table_mapping", "multi-table"); + } + SchemaType::SingleTableMultiMeasure => { + env::set_var("table_mapping", "multi-table"); + env::set_var("single_table_name", "influxdb-measures"); + } + } +} + +fn set_base_environment_variables() { env::set_var("database_name", DATABASE_NAME); env::set_var("enable_database_creation", "true"); env::set_var("enable_table_creation", "true"); @@ -96,7 +109,8 @@ fn random_number(low: T, high: T) -> T { #[tokio::test] async fn test_mtmm_basic() -> Result<(), Error> { // Tests ingesting a single point. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -132,7 +146,8 @@ async fn test_mtmm_basic() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_create_database() -> Result<(), Error> { // Tests ingesting a single point and creating a database. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let test_create_database_name = "test_create_database_influxdb_timestream_connector_integ"; env::set_var("database_name", test_create_database_name); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) @@ -184,7 +199,8 @@ async fn test_mtmm_create_database() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_unusual_query_parameters() -> Result<(), Error> { // Tests ingesting a single point with a query parameters key with unusual spelling. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -220,7 +236,8 @@ async fn test_mtmm_unusual_query_parameters() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_no_query_parameters() -> Result<(), Error> { // Tests ingesting a single point without query parameters. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -254,7 +271,8 @@ async fn test_mtmm_multiple_timestamps() -> Result<(), Error> { // Tests ingesting a single point with two timestamps. // Note, the connector either returns JSON with a 200 status code or an Error. This is so that // the dead letter queue works when the connector is deployed as part of a stack. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -289,7 +307,8 @@ async fn test_mtmm_multiple_timestamps() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_many_tags_many_fields() -> Result<(), Error> { // Tests ingesting a single point with 50 tags and 50 fields. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -337,7 +356,8 @@ async fn test_mtmm_many_tags_many_fields() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_float() -> Result<(), Error> { // Tests ingesting a single point with a float value for the field. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -373,7 +393,8 @@ async fn test_mtmm_float() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_string() -> Result<(), Error> { // Tests ingesting a single point with a string value for the field. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -409,7 +430,8 @@ async fn test_mtmm_string() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_bool() -> Result<(), Error> { // Tests ingesting a single point with a bool value for the field. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -448,7 +470,8 @@ async fn test_mtmm_max_tag_length() -> Result<(), Error> { // is 60, the maximum allowed dimension name length, and its value // is 1988 characters long. The length of the tag key and tag value // together amount to the maximum size for a dimension pair, 2 kilobytes. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -488,7 +511,8 @@ async fn test_mtmm_beyond_max_tag_length() -> Result<(), Error> { // is 60, the maximum allowed dimension name length, and its value // is 1989 characters long. The length of the tag key and tag value // together exceed the maximum size for a dimension pair, 2 kilobytes. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -525,7 +549,8 @@ async fn test_mtmm_max_field_length() -> Result<(), Error> { // Tests ingesting a single point with a field where the length // of the field key is the maximum measure name, 256, and the length // of the field value is the maximum measure value size, 2048. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -564,7 +589,8 @@ async fn test_mtmm_beyond_max_field_length() -> Result<(), Error> { // Tests ingesting a single point with a field where the length // of the field key is the maximum measure name, 256, and the length // of the field value is beyond the maximum measure value size, 2048. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -601,7 +627,8 @@ async fn test_mtmm_max_unique_field_keys() -> Result<(), Error> { // Tests ingesting a batch of points where the number of unique field keys // in the batch equals the maximum number of unique measures for a single // table, 1024. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -644,7 +671,8 @@ async fn test_mtmm_beyond_max_unique_field_keys() -> Result<(), Error> { // Tests ingesting a batch of points where the number of unique field keys // in the batch exceeds the maximum number of unique measures for a single // table, 1024. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -686,7 +714,8 @@ async fn test_mtmm_max_unique_tag_keys() -> Result<(), Error> { // Tests ingesting a batch of points where the number of unique tag keys // in the batch equals the maximum number of unique dimensions for a single // table, 128. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -729,7 +758,8 @@ async fn test_mtmm_beyond_max_unique_tag_keys() -> Result<(), Error> { // Tests ingesting a batch of points where the number of unique tag keys // in the batch exceeds the maximum number of unique dimensions for a single // table, 128. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -771,7 +801,8 @@ async fn test_mtmm_max_table_name_length() -> Result<(), Error> { // Tests ingesting a single point with measurement with length // equal to the maximum number of bytes a Timestream table name can // have. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -809,7 +840,8 @@ async fn test_mtmm_beyond_max_table_name_length() -> Result<(), Error> { // Tests ingesting a single point with measurement with length // exceeding the maximum number of bytes a Timestream table name can // have. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -843,7 +875,8 @@ async fn test_mtmm_beyond_max_table_name_length() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_nanosecond_precision() -> Result<(), Error> { // Tests ingesting a single point with nanosecond precision. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -881,7 +914,8 @@ async fn test_mtmm_nanosecond_precision() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_microsecond_precision() -> Result<(), Error> { // Tests ingesting a single point with microsecond precision. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -917,7 +951,8 @@ async fn test_mtmm_microsecond_precision() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_second_precision() -> Result<(), Error> { // Tests ingesting a single point with second precision. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -953,7 +988,8 @@ async fn test_mtmm_second_precision() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_no_precision() -> Result<(), Error> { // Tests ingesting a single point without precision supplied. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -992,7 +1028,8 @@ async fn test_mtmm_no_precision() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_empty_point() -> Result<(), Error> { // Tests ingesting an empty string. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -1016,7 +1053,8 @@ async fn test_mtmm_empty_point() -> Result<(), Error> { #[tokio::test] pub async fn test_mtmm_small_timestamp() -> Result<(), Error> { // Tests ingesting with a single-digit millisecond timestamp. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -1052,7 +1090,8 @@ pub async fn test_mtmm_small_timestamp() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_5_measurements() -> Result<(), Error> { // Tests ingesting a batch with five measurements. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -1096,7 +1135,8 @@ async fn test_mtmm_5_measurements() -> Result<(), Error> { #[tokio::test] async fn test_mtmm_100_measurements() -> Result<(), Error> { // Tests ingesting a batch with 100 measurements. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await .expect("Failed to get client"); @@ -1141,7 +1181,8 @@ async fn test_mtmm_100_measurements() -> Result<(), Error> { async fn test_mtmm_5000_batch() -> Result<(), Error> { // Tests ingesting a batch of 5000 points with a single measurement. // 5000 is the recommended batch size for InfluxDB v2 OSS. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); // Cleanup let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) .await @@ -1183,7 +1224,8 @@ async fn test_mtmm_5000_batch() -> Result<(), Error> { #[should_panic] async fn test_mtmm_no_credentials() { // Tests ingesting without AWS credentials. This test should panic. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) .credentials_provider(Credentials::new("", "", None, None, "test")) @@ -1221,7 +1263,8 @@ async fn test_mtmm_no_credentials() { #[should_panic] async fn test_mtmm_incorrect_credentials() { // Tests ingesting with incorrect AWS credentials. This test should panic. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) .credentials_provider(Credentials::new( @@ -1265,7 +1308,8 @@ async fn test_mtmm_incorrect_credentials() { async fn test_mtmm_custom_dimension_partition_key_optional_enforcement() -> Result<(), Error> { // Tests ingesting a single point and specifying a valid configuration for // a custom dimension partition key with optional enforcement. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); env::set_var("custom_partition_key_type", "dimension"); env::set_var("custom_partition_key_dimension", "nomatch"); env::set_var("enforce_custom_partition_key", "false"); @@ -1306,7 +1350,8 @@ async fn test_mtmm_custom_dimension_partition_key_required_enforcement_accepted( ) -> Result<(), Error> { // Tests ingesting a single point and specifying a valid configuration for // a custom dimension partition key with required enforcement and a successful ingestion. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); env::set_var("custom_partition_key_type", "dimension"); env::set_var("custom_partition_key_dimension", "tag1"); env::set_var("enforce_custom_partition_key", "true"); @@ -1347,7 +1392,8 @@ async fn test_mtmm_custom_dimension_partition_key_required_enforcement_rejected( ) -> Result<(), Error> { // Tests ingesting a single point and specifying a valid configuration for // a custom dimension partition key with required enforcement and an unsuccessful ingestion. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); env::set_var("custom_partition_key_type", "dimension"); env::set_var("custom_partition_key_dimension", "nomatch"); env::set_var("enforce_custom_partition_key", "true"); @@ -1386,7 +1432,8 @@ async fn test_mtmm_custom_dimension_partition_key_required_enforcement_rejected( async fn test_mtmm_custom_dimension_partition_key_no_dimension() -> Result<(), Error> { // Tests ingesting a single point and specifying a configuration for // a custom dimension partition key without a dimension specified. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); env::set_var("custom_partition_key_type", "dimension"); env::remove_var("custom_partition_key_dimension"); env::set_var("enforce_custom_partition_key", "false"); @@ -1425,7 +1472,8 @@ async fn test_mtmm_custom_dimension_partition_key_no_dimension() -> Result<(), E async fn test_mtmm_custom_dimension_partition_key_no_enforcement() -> Result<(), Error> { // Tests ingesting a single point and specifying a configuration for // a custom dimension partition key without an enforcement configuration specified. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); env::set_var("custom_partition_key_type", "dimension"); env::set_var("custom_partition_key_dimension", "tag1"); env::remove_var("enforce_custom_partition_key"); @@ -1464,7 +1512,8 @@ async fn test_mtmm_custom_dimension_partition_key_no_enforcement() -> Result<(), async fn test_mtmm_custom_measure_partition_key() -> Result<(), Error> { // Tests ingesting a single point and specifying a valid configuration for // a custom measure partition key. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); env::set_var("custom_partition_key_type", "measure"); env::remove_var("custom_partition_key_dimension"); env::remove_var("enforce_custom_partition_key"); @@ -1504,7 +1553,8 @@ async fn test_mtmm_custom_measure_partition_key() -> Result<(), Error> { async fn test_mtmm_custom_measure_partition_key_with_dimension() -> Result<(), Error> { // Tests ingesting a single point and specifying a valid configuration for // a custom measure partition key with a dimension specified. The dimension should be ignored. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); env::set_var("custom_partition_key_type", "measure"); env::set_var("custom_partition_key_dimension", "should_be_ignored"); env::remove_var("enforce_custom_partition_key"); @@ -1545,7 +1595,8 @@ async fn test_mtmm_custom_measure_partition_key_with_enforcement() -> Result<(), // Tests ingesting a single point and specifying a valid configuration for // a custom measure partition key with an enforcement configuration specified. // The enforcement configuration should be ignored. - set_environment_variables(); + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::MultiTableMultiMeasure); env::set_var("custom_partition_key_type", "measure"); env::remove_var("custom_partition_key_dimension"); env::set_var("enforce_custom_partition_key", "false"); @@ -1580,3 +1631,100 @@ async fn test_mtmm_custom_measure_partition_key_with_enforcement() -> Result<(), assert!(response?["statusCode"] == 200); Ok(()) } + +#[tokio::test] +async fn test_stmm_basic() -> Result<(), Error> { + // Tests ingesting a single point. + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::SingleTableMultiMeasure); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + let client = Arc::new(client); + + let lp_measurement_name = String::from("readings"); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::offset::Utc::now().timestamp_millis() + ); + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": point }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); + + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), vec![lp_measurement_name]); + cleanup_batch.cleanup(&client).await; + + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); + Ok(()) +} + +#[tokio::test] +async fn test_stmm_varying_metrics() -> Result<(), Error> { + // Tests ingesting a batch with 100 measurements. + set_base_environment_variables(); + set_table_mapping_env_variables(SchemaType::SingleTableMultiMeasure); + let client = influxdb_timestream_connector::timestream_utils::get_connection(REGION) + .await + .expect("Failed to get client"); + let client = Arc::new(client); + + let mut table_names_to_delete = Vec::::new(); + + let lp_readings_measurement_name = String::from("readings"); + let mut lp_batch = String::new(); + for i in 0..10 { + let lp_readings_measurement_name = format!("{lp_readings_measurement_name}{i}").to_string(); + table_names_to_delete.push(lp_readings_measurement_name.clone()); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_readings_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::Utc::now().timestamp_millis() + ); + lp_batch.push_str(&point); + } + + let lp_velocity_measurement_name = String::from("velocity"); + for i in 0..10 { + let lp_velocity_measurement_name = format!("{lp_velocity_measurement_name}{i}").to_string(); + table_names_to_delete.push(lp_readings_measurement_name.clone()); + + let point = format!( + "{},tag1={} field1={}i {}\n", + lp_velocity_measurement_name, + random_string(9), + random_number(0, 100001), + chrono::Utc::now().timestamp_millis() + ); + lp_batch.push_str(&point); + } + + let query_parameters = HashMap::from([("precision".to_string(), "ms".to_string())]); + let request = LambdaEvent::::new( + json!({ "queryStringParameters": query_parameters, "body": lp_batch }), + Context::default(), + ); + + let response = influxdb_timestream_connector::lambda_handler(&client, request).await; + println!("Response {:?}", response); + + let mut cleanup_batch = CleanupBatch::new(DATABASE_NAME.to_string(), table_names_to_delete); + cleanup_batch.cleanup(&client).await; + + assert!(response.is_ok()); + assert!(response?["statusCode"] == 200); + Ok(()) +} From 4487277625ad145abdce1c5130145326385791f0 Mon Sep 17 00:00:00 2001 From: Forest Vey Date: Fri, 15 Nov 2024 14:58:53 -0800 Subject: [PATCH 09/11] Multi-table Mapping Default for InfluxDB Timestream Connector (#25) * Set default table mapping to multi-table for the InfluxDB Timestream Connector. Signed-off-by: forestmvey * Fixing table formatting in README for InfluxDB Timestream Connector. Signed-off-by: forestmvey * Updating README to reference multi-table for the default table mapping. Signed-off-by: forestmvey --------- Signed-off-by: forestmvey --- .../influxdb-timestream-connector/Cargo.toml | 2 +- .../influxdb-timestream-connector/README.md | 24 +++++++++---------- .../template.yml | 2 +- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml index c9d5b9e8..a6b64831 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml +++ b/integrations/influxdb_connector/influxdb-timestream-connector/Cargo.toml @@ -32,7 +32,7 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } [package.metadata.lambda.env] region = "us-east-1" database_name = "influxdb-line-protocol" -table_mapping = "single-table" +table_mapping = "multi-table" single_table_name = "influxdb-measures" measure_name_for_multi_measure_records = "influxdb-measure" enable_database_creation = "true" diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/README.md b/integrations/influxdb_connector/influxdb-timestream-connector/README.md index 294a5c96..c7c9e9a9 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/README.md +++ b/integrations/influxdb_connector/influxdb-timestream-connector/README.md @@ -17,11 +17,11 @@ The following diagram shows a high-level overview of the connector's architectur The following table shows how the connector maps line protocol elements to Timestream for LiveAnalytics record attributes when table mapping is set to single table. | Line Protocol Element | Timestream Record Attribute | -|-----------------------|---------------------------| -| Timestamp | Time | -| Tags | Dimensions | -| Fields | Measures | -| Measurements | Measure names | +|-----------------------|-----------------------------| +| Timestamp | Time | +| Tags | Dimensions | +| Fields | Measures | +| Measurements | Measure names | Single table mapping ingests all line protocol points ingested through the InfluxDB Timestream Connector to the table defined with the `single_table_name` environment variable. The `measure_name` in each Timestream record is derived from the line protocol measurement. @@ -37,7 +37,7 @@ weather,location=us-midwest,season=summer temperature=82.0,humidity=71.0 1706480 #### Resulting influxdb-measures Timestream for LiveAnalytics Table | host | region | location | season | measure_name | time | value | average | temperature | humidity | -|----------|---------|---------------------|------------------|-------------------------------|-------|---------|-------------|----------| +|----------|---------|------------|--------|------------------|-------------------------------|-------|---------|-------------|----------| | server01 | us-west | | | cpu_load_short | 2024-08-30 23:07:54.000000000 | 0.64 | 1.24 | | | | | | us-midwest | summer | weather | 2024-01-22 26:07:33.000000000 | | | 82.0 | 71.0 | @@ -47,11 +47,11 @@ weather,location=us-midwest,season=summer temperature=82.0,humidity=71.0 1706480 The following table shows how the connector maps line protocol elements to Timestream for LiveAnalytics record attributes. | Line Protocol Element | Timestream Record Attribute | -|-----------------------|---------------------------| -| Timestamp | Time | -| Tags | Dimensions | -| Fields | Measures | -| Measurements | Table names | +|-----------------------|-----------------------------| +| Timestamp | Time | +| Tags | Dimensions | +| Fields | Measures | +| Measurements | Table names | A Timestream record's `measure_name` field is not derived from any element of ingested line protocol. Due to the multi-measure record translation, the connector sets the `measure_name` for each multi-measure record to the value of a Lambda environment variable. When [deployed as part of a CloudFormation stack](#aws-cloudformation-deployment), this can be customized by overriding the `MeasureNameForMultiMeasureRecords` parameter. When [deployed locally](#local-deployment), this can be customized by setting the `measure_name_for_multi_measure_records` environment variable. @@ -110,7 +110,7 @@ The following parameters are available when deploying the connector as part of a | `RestApiGatewayTimeoutInMillis` | The maximum number of milliseconds a REST API Gateway event will wait before timing out. | `30000` | | `RustLog` | The log level to use for the Lambda function. Typical values are error, warn, info, debug, trace, and off. Use trace in order to log the execution time of each function. | `INFO` | | `SingleTableName` | Determines the table name for ingestion when table mapping is type single-table. | `influxdb-measures` | -| `TableMapping` | Determines whether to ingest all records to a single table or to multiple tables. | `single-table` | +| `TableMapping` | Determines whether to ingest all records to a single table or to multiple tables. | `multi-table` | | `WriteThrottlingBurstLimit` | The number of burst requests per second that the REST API Gateway permits. | `1200` | ##### SAM Deployment Steps diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml index 3482d33b..1cbeb0d6 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml +++ b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml @@ -34,7 +34,7 @@ Parameters: Description: The value to use in records as the measure name. TableMapping: Type: String - Default: single-table + Default: multi-table Description: Maps records ingested to a single table or multiple tables. SingleTableName: Type: String From 771715519970ff9b77e42a1f0481d51031751d39 Mon Sep 17 00:00:00 2001 From: Trevor Bonas <45324987+trevorbonas@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:52:50 -0800 Subject: [PATCH 10/11] Add support for returning gzip compressed responses (#29) * Add gzip support to Go client * Add gzip support to template * Ignore custom_partition_key_type if it is invalid option * Add comment about lack of local gzip support --- .../influxdb-timestream-connector/README.md | 4 ++++ .../src/records_builder.rs | 10 +--------- .../influxdb-timestream-connector/template.yml | 1 + .../go/line-protocol-client-demo.go | 3 +++ 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/README.md b/integrations/influxdb_connector/influxdb-timestream-connector/README.md index c7c9e9a9..097735be 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/README.md +++ b/integrations/influxdb_connector/influxdb-timestream-connector/README.md @@ -623,3 +623,7 @@ In order to ingest to Timestream for LiveAnalytics, every line protocol point mu ### Query String Parameters The connector expects query string parameters to be included as `queryParameters` or `queryStringParameters` in requests. + +### Lack of Local Gzip Support + +The connector, when deployed as part of a CloudFormation stack, supports requests sent with Content-Type and Accept-Encoding headers set to `gzip`. However, when run locally with either Cargo Lambda or the SAM CLI, gzip compression is not supported. diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs index 545ee4ea..23e40c11 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs +++ b/integrations/influxdb_connector/influxdb-timestream-connector/src/records_builder.rs @@ -1,5 +1,5 @@ use crate::metric::Metric; -use crate::timestream_utils::{DIMENSION_PARTITION_KEY_TYPE, MEASURE_PARTITION_KEY_TYPE}; +use crate::timestream_utils::DIMENSION_PARTITION_KEY_TYPE; use anyhow::{anyhow, Error}; use aws_sdk_timestreamwrite::types as timestream_types; use std::{collections::HashMap, fmt::Debug}; @@ -141,14 +141,6 @@ pub fn validate_env_variables() -> Result<(), Error> { let custom_partition_key_type = std::env::var("custom_partition_key_type"); if let Ok(custom_partition_key_type) = custom_partition_key_type { - if custom_partition_key_type != DIMENSION_PARTITION_KEY_TYPE - && custom_partition_key_type != MEASURE_PARTITION_KEY_TYPE - { - return Err(anyhow!( - format!("custom_partition_key_type can only be {DIMENSION_PARTITION_KEY_TYPE} or {MEASURE_PARTITION_KEY_TYPE}") - )); - } - // Check required environment variables for when custom partition key type is "dimension." If it is "measure," // no other environment variables are necessary. diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml index 1cbeb0d6..4912ca53 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml +++ b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml @@ -128,6 +128,7 @@ Resources: Type: AWS::ApiGateway::RestApi Properties: Name: !Sub "${RestApiGatewayName}-${AWS::StackName}" + MinimumCompressionSize: 0 ApiResource: Type: AWS::ApiGateway::Resource diff --git a/integrations/influxdb_connector/sample-influxdb-clients/go/line-protocol-client-demo.go b/integrations/influxdb_connector/sample-influxdb-clients/go/line-protocol-client-demo.go index 4d09b0df..57a97c15 100644 --- a/integrations/influxdb_connector/sample-influxdb-clients/go/line-protocol-client-demo.go +++ b/integrations/influxdb_connector/sample-influxdb-clients/go/line-protocol-client-demo.go @@ -30,6 +30,7 @@ var( endpoint string dataset string precision string + useGzip bool ) func main() { @@ -38,10 +39,12 @@ func main() { flag.StringVar(&endpoint, "endpoint", "http://localhost:9000", "Endpoint for InfluxDB Timestream Connector") flag.StringVar(&dataset, "dataset", "../data/bird-migration.line", "Line protocol dataset being ingested") flag.StringVar(&precision, "precision", "ns", "Precision for line protocol: nanoseconds=ns, milliseconds=ms, microseconds=us, seconds=s") + flag.BoolVar(&useGzip, "gzip", false, "Whether to compress requests with gzip") flag.Parse() opts := influxdb2.DefaultOptions() opts.HTTPOptions().SetHTTPDoer(&SigV4HeaderSetter{RequestDoer: opts.HTTPClient(),}) + opts.WriteOptions().SetUseGZip(useGzip) switch { case precision == "ns": From d71e956014a5b86206db7dd00ccaca2a88af99c8 Mon Sep 17 00:00:00 2001 From: Forest Vey Date: Fri, 3 Jan 2025 08:28:49 -0800 Subject: [PATCH 11/11] Defining Least-privilege Lambda Invocation Permissions for the InfluxDB Timestream Connector (#33) * Defining least-privilege Lambda invocation permissions for the connector. Adding output for IAM policy when deploying using the SAM template. Signed-off-by: forestmvey * Making stage name dynamic for output least privilege IAM policy. Signed-off-by: forestmvey * Adding section in README for ingestion permissions of Lambda function. Signed-off-by: forestmvey * Revising wording for IAM permissions in README. Signed-off-by: forestmvey --------- Signed-off-by: forestmvey --- .../influxdb-timestream-connector/README.md | 41 +++++++++++++++++-- .../template.yml | 13 ++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/README.md b/integrations/influxdb_connector/influxdb-timestream-connector/README.md index 097735be..018e7118 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/README.md +++ b/integrations/influxdb_connector/influxdb-timestream-connector/README.md @@ -260,6 +260,11 @@ The following permissions are the least-privilege permissions for deploying and The following is the least-privilege IAM permissions for deploying the connector. +Replace all items listed below in the IAM policy with values from your AWS account: + +- *{region}* — The AWS region where the InfluxDB Timestream Connector is deployed. +- *{account-id}* — The AWS account ID used to deploy the connector. + ```json { "Version": "2012-10-17", @@ -391,7 +396,37 @@ The following is the least-privilege IAM permissions for deploying the connector ### IAM Execution Permissions -The following is the least privileged IAM permissions for executing the connector. +The following are the least privileged IAM permissions required for invoking the deployed InfluxDB Timestream connector REST API Gateway. + +Replace all items listed below in the IAM policy with values from your AWS account: + +- *{region}* — The AWS region where the InfluxDB Timestream Connector is deployed. +- *{account-id}* — The AWS account ID used to deploy the connector. +- *{api-id}* — The API ID for the deployed REST API Gateway. +- *{api-stage-name}* — The stage name for the deployed REST API Gateway. + + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "execute-api:Invoke", + "Resource": "arn:aws:execute-api:{region}:{account-id}:{api-id}/{api-stage-name}/POST/api/v2/write" + } + ] +} +``` + +### IAM Lambda Permissions + +The following are the IAM permissions required for the InfluxDB Timestream Connector Lambda function to ingest data into Timestream for LiveAnalytics. This IAM policy is attached to the Lambda function when deployed with the CloudFormation template. Additional policies are also attached for logging and DLQ functionalities. For the complete list of IAM permissions attached to the Lambda function, see the [template.yml](./template.yml). + +All items listed below in the IAM policy are associated to the equivalent values from your AWS account: + +- *{region}* — The AWS region where the InfluxDB Timestream Connector is deployed. +- *{account-id}* — The AWS account ID used to deploy the connector. ```json { @@ -403,7 +438,7 @@ The following is the least privileged IAM permissions for executing the connecto "timestream:WriteRecords", "timestream:Select", "timestream:DescribeTable", - "timestream:CreateTable" + "timestream:CreateTable" ], "Resource": "arn:aws:timestream:{region}:{account-id}:database/influxdb-line-protocol/table/*" }, @@ -418,7 +453,7 @@ The following is the least privileged IAM permissions for executing the connecto "Effect": "Allow", "Action": [ "timestream:DescribeDatabase", - "timestream:CreateDatabase" + "timestream:CreateDatabase" ], "Resource": "arn:aws:timestream:{region}:{account-id}:database/influxdb-line-protocol" } diff --git a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml index 4912ca53..4b78ea06 100644 --- a/integrations/influxdb_connector/influxdb-timestream-connector/template.yml +++ b/integrations/influxdb_connector/influxdb-timestream-connector/template.yml @@ -566,6 +566,19 @@ Outputs: Endpoint: Description: The endpoint for the REST API Gateway. Value: !Sub https://${RestApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${RestApiGatewayStageName} + ExecutionPolicy: + Description: The IAM policy permissions required for invoking the InfluxDB Timestream Connector. + Value: !Sub | + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "execute-api:Invoke", + "Resource": "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RestApiGateway}/${RestApiGatewayStageName}/POST/api/v2/write" + } + ] + } LambdaDeadLetterQueueName: Description: The name of the dead letter queue used by the Lambda function. Condition: IsAsyncEnabled