Skip to content

Commit 5c88aeb

Browse files
authored
feat: Add time_creation as first-class metadata (#210)
1 parent 267ab0e commit 5c88aeb

File tree

8 files changed

+63
-5
lines changed

8 files changed

+63
-5
lines changed

clients/python/src/objectstore_client/metadata.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
import re
55
from collections.abc import Iterable, Iterator, Mapping
66
from dataclasses import dataclass
7-
from datetime import timedelta
7+
from datetime import datetime, timedelta
88
from typing import Literal, TypeVar, cast
99

1010
Compression = Literal["zstd"] | Literal["none"]
1111

1212
HEADER_EXPIRATION = "x-sn-expiration"
13+
HEADER_TIME_CREATED = "x-sn-time-created"
1314
HEADER_META_PREFIX = "x-snme-"
1415

1516

@@ -31,13 +32,21 @@ class Metadata:
3132
content_type: str | None
3233
compression: Compression | None
3334
expiration_policy: ExpirationPolicy | None
35+
time_created: datetime | None
36+
"""
37+
Timestamp indicating when the object was created or the last time it was replaced.
38+
This means that a PUT request to an existing object causes this value to be bumped.
39+
This field is computed by the server, it cannot be set by clients.
40+
"""
41+
3442
custom: dict[str, str]
3543

3644
@classmethod
3745
def from_headers(cls, headers: Mapping[str, str]) -> Metadata:
3846
content_type = "application/octet-stream"
3947
compression = None
4048
expiration_policy = None
49+
time_created = None
4150
custom_metadata = {}
4251

4352
for k, v in headers.items():
@@ -47,13 +56,16 @@ def from_headers(cls, headers: Mapping[str, str]) -> Metadata:
4756
compression = cast(Compression | None, v)
4857
elif k == HEADER_EXPIRATION:
4958
expiration_policy = parse_expiration(v)
59+
elif k == HEADER_TIME_CREATED:
60+
time_created = datetime.fromisoformat(v)
5061
elif k.startswith(HEADER_META_PREFIX):
5162
custom_metadata[k[len(HEADER_META_PREFIX) :]] = v
5263

5364
return Metadata(
5465
content_type=content_type,
5566
compression=compression,
5667
expiration_policy=expiration_policy,
68+
time_created=time_created,
5769
custom=custom_metadata,
5870
)
5971

clients/python/tests/test_e2e.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def test_full_cycle(server_url: str) -> None:
110110

111111
retrieved = session.get(object_key)
112112
assert retrieved.payload.read() == data
113+
assert retrieved.metadata.time_created is not None
113114

114115
session.delete(object_key)
115116

clients/rust/src/tests.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ async fn stores_uncompressed() {
3434

3535
assert_eq!(metadata.compression, None);
3636
assert_eq!(received.as_ref(), b"oh hai!");
37+
assert!(metadata.time_created.is_some());
3738
}
3839

3940
#[tokio::test]

objectstore-server/src/endpoints.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
//! Contains all HTTP endpoint handlers.
22
33
use std::io;
4+
use std::time::SystemTime;
45

56
use anyhow::Context;
67
use axum::body::Body;
@@ -44,8 +45,10 @@ async fn put_object(
4445
body: Body,
4546
) -> ApiResult<impl IntoResponse> {
4647
populate_sentry_scope(&path);
47-
let metadata =
48+
49+
let mut metadata =
4850
Metadata::from_headers(&headers, "").context("extracting metadata from headers")?;
51+
metadata.time_created = Some(SystemTime::now());
4952

5053
let stream = body.into_data_stream().map_err(io::Error::other).boxed();
5154
let key = state.service.put_object(path, &metadata, stream).await?;

objectstore-service/src/backend/bigtable.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,7 @@ mod tests {
371371
let path = make_key();
372372
let metadata = Metadata {
373373
content_type: "text/plain".into(),
374+
time_created: Some(SystemTime::now()),
374375
custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
375376
..Default::default()
376377
};

objectstore-service/src/backend/gcs.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ struct GcsObject {
5555
/// without having to stream it.
5656
pub size: Option<String>,
5757

58+
/// Timestamp of when this object was created.
59+
#[serde(
60+
default,
61+
skip_serializing_if = "Option::is_none",
62+
with = "humantime_serde"
63+
)]
64+
pub time_created: Option<SystemTime>,
65+
5866
/// User-provided metadata, including our built-in metadata.
5967
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
6068
pub metadata: BTreeMap<GcsMetaKey, String>,
@@ -68,6 +76,7 @@ impl GcsObject {
6876
size: metadata.size.map(|size| size.to_string()),
6977
content_encoding: None,
7078
custom_time: None,
79+
time_created: metadata.time_created,
7180
metadata: BTreeMap::new(),
7281
};
7382

@@ -113,6 +122,7 @@ impl GcsObject {
113122
let content_type = self.content_type;
114123
let compression = self.content_encoding.map(|s| s.parse()).transpose()?;
115124
let size = self.size.map(|size| size.parse()).transpose()?;
125+
let time_created = self.time_created;
116126

117127
// At this point, all built-in metadata should have been removed from self.metadata.
118128
let mut custom = BTreeMap::new();
@@ -131,8 +141,9 @@ impl GcsObject {
131141
content_type,
132142
expiration_policy,
133143
compression,
134-
custom,
135144
size,
145+
custom,
146+
time_created,
136147
})
137148
}
138149
}
@@ -466,6 +477,7 @@ mod tests {
466477
expiration_policy: ExpirationPolicy::Manual,
467478
compression: None,
468479
custom: BTreeMap::from_iter([("hello".into(), "world".into())]),
480+
time_created: Some(SystemTime::now()),
469481
size: None,
470482
};
471483

@@ -480,6 +492,7 @@ mod tests {
480492
assert_eq!(str_payload, "hello, world");
481493
assert_eq!(meta.content_type, metadata.content_type);
482494
assert_eq!(meta.custom, metadata.custom);
495+
assert!(metadata.time_created.is_some());
483496

484497
Ok(())
485498
}

objectstore-service/src/backend/local_fs.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ impl Backend for LocalFsBackend {
103103

104104
#[cfg(test)]
105105
mod tests {
106-
use std::time::Duration;
106+
use std::time::{Duration, SystemTime};
107107

108108
use bytes::BytesMut;
109109
use futures_util::TryStreamExt;
@@ -129,6 +129,7 @@ mod tests {
129129
is_redirect_tombstone: None,
130130
content_type: "text/plain".into(),
131131
expiration_policy: ExpirationPolicy::TimeToIdle(Duration::from_secs(3600)),
132+
time_created: Some(SystemTime::now()),
132133
compression: Some(Compression::Zstd),
133134
custom: [("foo".into(), "bar".into())].into(),
134135
size: None,

objectstore-types/src/lib.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ use std::str::FromStr;
1313
use std::time::{Duration, SystemTime};
1414

1515
use http::header::{self, HeaderMap, HeaderName};
16-
use humantime::{format_duration, format_rfc3339_seconds, parse_duration};
16+
use humantime::{
17+
format_duration, format_rfc3339_micros, format_rfc3339_seconds, parse_duration, parse_rfc3339,
18+
};
1719
use serde::{Deserialize, Serialize};
1820

1921
/// The custom HTTP header that contains the serialized [`ExpirationPolicy`].
2022
pub const HEADER_EXPIRATION: &str = "x-sn-expiration";
2123
/// The custom HTTP header that contains the serialized redirect tombstone.
2224
pub const HEADER_REDIRECT_TOMBSTONE: &str = "x-sn-redirect-tombstone";
25+
/// The custom HTTP header that contains the object creation time.
26+
pub const HEADER_TIME_CREATED: &str = "x-sn-time-created";
2327
/// The prefix for custom HTTP headers containing custom per-object metadata.
2428
pub const HEADER_META_PREFIX: &str = "x-snme-";
2529

@@ -41,6 +45,9 @@ pub enum Error {
4145
/// The content type is invalid.
4246
#[error("invalid content type")]
4347
InvalidContentType(#[from] mediatype::MediaTypeError),
48+
/// The creation time is invalid.
49+
#[error("invalid creation time")]
50+
InvalidCreationTime(#[from] humantime::TimestampError),
4451
}
4552
impl From<http::header::InvalidHeaderValue> for Error {
4653
fn from(err: http::header::InvalidHeaderValue) -> Self {
@@ -190,6 +197,13 @@ pub struct Metadata {
190197
#[serde(skip_serializing_if = "ExpirationPolicy::is_manual")]
191198
pub expiration_policy: ExpirationPolicy,
192199

200+
/// The creation/last replacement time of the object, if known.
201+
///
202+
/// This is populated by the server when performing a POST or PUT request, i.e. when an object is
203+
/// first created or when an existing object is overwritten.
204+
#[serde(skip_serializing_if = "Option::is_none")]
205+
pub time_created: Option<SystemTime>,
206+
193207
/// The content type of the object, if known.
194208
pub content_type: Cow<'static, str>,
195209

@@ -242,6 +256,11 @@ impl Metadata {
242256
metadata.is_redirect_tombstone = Some(true);
243257
}
244258
}
259+
HEADER_TIME_CREATED => {
260+
let timestamp = value.to_str()?;
261+
let time = parse_rfc3339(timestamp)?;
262+
metadata.time_created = Some(time);
263+
}
245264
_ => {
246265
// customer-provided metadata
247266
if let Some(name) = name.strip_prefix(HEADER_META_PREFIX) {
@@ -268,6 +287,7 @@ impl Metadata {
268287
content_type,
269288
compression,
270289
expiration_policy,
290+
time_created,
271291
size: _,
272292
custom,
273293
} = self;
@@ -294,6 +314,11 @@ impl Metadata {
294314
headers.append("x-goog-custom-time", expires_at.to_string().parse()?);
295315
}
296316
}
317+
if let Some(time) = time_created {
318+
let name = HeaderName::try_from(format!("{prefix}{HEADER_TIME_CREATED}"))?;
319+
let timestamp = format_rfc3339_micros(*time);
320+
headers.append(name, timestamp.to_string().parse()?);
321+
}
297322

298323
// customer-provided metadata
299324
for (key, value) in custom {
@@ -317,6 +342,7 @@ impl Default for Metadata {
317342
Self {
318343
is_redirect_tombstone: None,
319344
expiration_policy: ExpirationPolicy::Manual,
345+
time_created: None,
320346
content_type: DEFAULT_CONTENT_TYPE.into(),
321347
compression: None,
322348
size: None,

0 commit comments

Comments
 (0)