Skip to content

Commit a647a70

Browse files
authored
Merge branch 'master' into kim/snapshot/fsync
2 parents f76e5b5 + e3060d2 commit a647a70

20 files changed

Lines changed: 565 additions & 158 deletions

File tree

crates/bindings-csharp/BSATN.Runtime.Tests/Tests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
namespace SpacetimeDB;
22

33
using System.Diagnostics.CodeAnalysis;
4+
using System.Globalization;
45
using System.Security.Cryptography;
6+
using System.Threading;
57
using CsCheck;
68
using SpacetimeDB.BSATN;
79
using Xunit;
@@ -186,6 +188,27 @@ public static void TimestampConversionChecks()
186188
Assert.Equal(+1, laterStamp.CompareTo(stamp));
187189
}
188190

191+
[Fact]
192+
public static void TimestampSqlLiteralUsesInvariantCulture()
193+
{
194+
var originalCulture = Thread.CurrentThread.CurrentCulture;
195+
196+
try
197+
{
198+
// Ensure the format is agnostic to the culture. Using ar-SA because it's different than Gregorian, which is used in UTC.
199+
Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("ar-SA");
200+
201+
Assert.Equal(
202+
"'2025-01-22T21:53:13.990639Z'",
203+
SqlLit.Timestamp(new Timestamp(1_737_582_793_990_639L)).ToString()
204+
);
205+
}
206+
finally
207+
{
208+
Thread.CurrentThread.CurrentCulture = originalCulture;
209+
}
210+
}
211+
189212
[Fact]
190213
public static void ConnectionIdComparableChecks()
191214
{

crates/bindings-csharp/BSATN.Runtime/QueryBuilder.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ public static SqlLiteral<ConnectionId> ConnectionId(ConnectionId value) =>
5858

5959
public static SqlLiteral<Uuid> Uuid(Uuid value) =>
6060
new(SqlFormat.FormatHexLiteral(value.ToString()));
61+
62+
public static SqlLiteral<Timestamp> Timestamp(Timestamp value) =>
63+
new(SqlFormat.FormatTimestampLiteral(value));
6164
}
6265

6366
public interface IQuery<TRow>
@@ -744,6 +747,24 @@ public static BoolExpr<TRow> Eq<TRow>(this Col<TRow, Uuid> col, Uuid value) =>
744747
public static BoolExpr<TRow> Neq<TRow>(this Col<TRow, Uuid> col, Uuid value) =>
745748
col.Neq(SqlLit.Uuid(value));
746749

750+
public static BoolExpr<TRow> Eq<TRow>(this Col<TRow, Timestamp> col, Timestamp value) =>
751+
col.Eq(SqlLit.Timestamp(value));
752+
753+
public static BoolExpr<TRow> Neq<TRow>(this Col<TRow, Timestamp> col, Timestamp value) =>
754+
col.Neq(SqlLit.Timestamp(value));
755+
756+
public static BoolExpr<TRow> Lt<TRow>(this Col<TRow, Timestamp> col, Timestamp value) =>
757+
col.Lt(SqlLit.Timestamp(value));
758+
759+
public static BoolExpr<TRow> Lte<TRow>(this Col<TRow, Timestamp> col, Timestamp value) =>
760+
col.Lte(SqlLit.Timestamp(value));
761+
762+
public static BoolExpr<TRow> Gt<TRow>(this Col<TRow, Timestamp> col, Timestamp value) =>
763+
col.Gt(SqlLit.Timestamp(value));
764+
765+
public static BoolExpr<TRow> Gte<TRow>(this Col<TRow, Timestamp> col, Timestamp value) =>
766+
col.Gte(SqlLit.Timestamp(value));
767+
747768
public static BoolExpr<TRow> Eq<TRow>(this IxCol<TRow, string> col, ReadOnlySpan<char> value) =>
748769
col.Eq(SqlLit.String(value));
749770

@@ -831,10 +852,18 @@ public static BoolExpr<TRow> Eq<TRow>(this IxCol<TRow, Uuid> col, Uuid value) =>
831852

832853
public static BoolExpr<TRow> Neq<TRow>(this IxCol<TRow, Uuid> col, Uuid value) =>
833854
col.Neq(SqlLit.Uuid(value));
855+
856+
public static BoolExpr<TRow> Eq<TRow>(this IxCol<TRow, Timestamp> col, Timestamp value) =>
857+
col.Eq(SqlLit.Timestamp(value));
858+
859+
public static BoolExpr<TRow> Neq<TRow>(this IxCol<TRow, Timestamp> col, Timestamp value) =>
860+
col.Neq(SqlLit.Timestamp(value));
834861
}
835862

836863
internal static class SqlFormat
837864
{
865+
private const string TimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.ffffff'Z'";
866+
838867
public static string QuoteIdent(string ident)
839868
{
840869
ident ??= string.Empty;
@@ -873,4 +902,12 @@ public static string FormatHexLiteral(string hex)
873902

874903
return $"0x{s}";
875904
}
905+
906+
public static string FormatTimestampLiteral(Timestamp timestamp) =>
907+
FormatStringLiteral(
908+
timestamp
909+
.ToStd()
910+
.ToUniversalTime()
911+
.ToString(TimestampFormat, CultureInfo.InvariantCulture)
912+
);
876913
}

crates/bindings-csharp/Runtime/Internal/ITable.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ protected static bool DoDelete(T row)
189189
using var stream = new MemoryStream();
190190
using var writer = new BinaryWriter(stream);
191191
// `datastore_delete_all_by_eq_bsatn` expects an array-like BSATN.
192-
// Write a length of 1 without actually wrapping the `row` into array
192+
// Write a length of 1 without actually wrapping the `row` into an array
193193
// (annoyingly, that would require passing `TRW` through a bunch of APIs).
194194
writer.Write(1U);
195195
row.WriteFields(writer);

crates/bindings-typescript/src/lib/filter.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { RowType, UntypedTableDef } from './table';
2+
import { Timestamp } from './timestamp';
23
import { Uuid } from './uuid';
34

4-
export type Value = string | number | boolean | Uuid;
5+
export type Value = string | number | boolean | Uuid | Timestamp;
56

67
export type Expr<Column extends string> =
78
| { type: 'eq'; key: Column; value: Value }
@@ -77,6 +78,13 @@ export function evaluate<Column extends string>(
7778
if (v instanceof Uuid && typeof expr.value === 'string') {
7879
return v.toString() === expr.value;
7980
}
81+
82+
if (v instanceof Timestamp) {
83+
// Value of the Column and passed Value are both Timestamps so compare microseconds.
84+
if (expr.value instanceof Timestamp) {
85+
return v.microsSinceUnixEpoch === expr.value.microsSinceUnixEpoch;
86+
}
87+
}
8088
}
8189
return false;
8290
}
@@ -103,6 +111,9 @@ function formatValue(v: Value): string {
103111
if (v instanceof Uuid) {
104112
return `'${v.toString()}'`;
105113
}
114+
if (v instanceof Timestamp) {
115+
return `'${v.toISOString()}'`;
116+
}
106117

107118
return '';
108119
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { eq, evaluate, toString } from '../src/lib/filter';
3+
import { ModuleContext, tablesToSchema } from '../src/lib/schema';
4+
import { table } from '../src/lib/table';
5+
import { Timestamp } from '../src/lib/timestamp';
6+
import { t } from '../src/lib/type_builders';
7+
8+
const peopleTableDef = table(
9+
{ name: 'people' },
10+
{
11+
createdAt: t.timestamp(),
12+
id: t.u32(),
13+
}
14+
);
15+
16+
const schemaDef = tablesToSchema(new ModuleContext(), {
17+
people: peopleTableDef,
18+
});
19+
20+
describe('filter.ts timestamp support', () => {
21+
it('evaluates timestamp equality by microseconds', () => {
22+
const ts = Timestamp.fromDate(new Date('2024-01-01T00:00:00.123Z'));
23+
24+
expect(evaluate(eq('createdAt', ts), { createdAt: ts })).toBe(true);
25+
expect(
26+
evaluate(eq('createdAt', ts), {
27+
createdAt: new Timestamp(ts.microsSinceUnixEpoch + 1n),
28+
})
29+
).toBe(false);
30+
});
31+
32+
it('renders timestamp literals as ISO strings', () => {
33+
const ts = Timestamp.fromDate(new Date('2024-01-01T00:00:00.123Z'));
34+
35+
expect(toString(schemaDef.tables.people, eq('createdAt', ts))).toBe(
36+
`createdAt = '2024-01-01T00:00:00.123000Z'`
37+
);
38+
});
39+
});

crates/bindings-typescript/tests/query.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const personTable = table(
3131
name: t.string(),
3232
age: t.u32(),
3333
active: t.bool(),
34+
createdAt: t.timestamp(),
3435
}
3536
);
3637

@@ -172,6 +173,26 @@ describe('TableScan.toSql', () => {
172173
);
173174
});
174175

176+
it('renders Timestamp literals as RFC3339 strings', () => {
177+
const qb = makeQueryBuilder(schemaDef);
178+
const timestamp = Timestamp.fromDate(new Date('2024-01-01T00:00:00.123Z'));
179+
const sql = toSql(qb.person.where(row => row.createdAt.eq(timestamp)));
180+
181+
expect(sql).toBe(
182+
`SELECT * FROM "person" WHERE "person"."createdAt" = '2024-01-01T00:00:00.123000Z'`
183+
);
184+
});
185+
186+
it('supports Timestamp comparisons in where predicates', () => {
187+
const qb = makeQueryBuilder(schemaDef);
188+
const timestamp = Timestamp.fromDate(new Date('2024-01-01T00:00:00.123Z'));
189+
const sql = toSql(qb.person.where(row => row.createdAt.gt(timestamp)));
190+
191+
expect(sql).toBe(
192+
`SELECT * FROM "person" WHERE "person"."createdAt" > '2024-01-01T00:00:00.123000Z'`
193+
);
194+
});
195+
175196
it('renders semijoin queries without additional filters', () => {
176197
const qb = makeQueryBuilder(schemaDef);
177198
const sql = toSql(

crates/bindings/tests/ui/tables.stderr

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,10 @@ error[E0277]: `&'a Alpha` cannot appear as an argument to an index filtering ope
203203
--> tests/ui/tables.rs:32:33
204204
|
205205
32 | ctx.db.delta().compound_a().find(Alpha { beta: 0 });
206-
| ^^^^ should be an integer type, `bool`, `String`, `&str`, `Identity`, `Uuid`, `ConnectionId`, `Hash` or a no-payload enum which derives `SpacetimeType`, not `&'a Alpha`
206+
| ^^^^ should be an integer type, `bool`, `String`, `&str`, `Identity`, `Uuid`, `Timestamp`, `ConnectionId`, `Hash` or a no-payload enum which derives `SpacetimeType`, not `&'a Alpha`
207207
|
208208
= help: the trait `for<'a> FilterableValue` is not implemented for `&'a Alpha`
209-
= note: The allowed set of types are limited to integers, bool, strings, `Identity`, `Uuid`, `ConnectionId`, `Hash` and no-payload enums which derive `SpacetimeType`,
209+
= note: The allowed set of types are limited to integers, bool, strings, `Identity`, `Uuid`, `Timestamp`, `ConnectionId`, `Hash` and no-payload enums which derive `SpacetimeType`,
210210
= help: the following other types implement trait `FilterableValue`:
211211
&ConnectionId
212212
&FunctionVisibility

crates/client-api/src/routes/database.rs

Lines changed: 91 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use crate::auth::{
99
SpacetimeIdentityToken,
1010
};
1111
use crate::routes::subscribe::generate_random_connection_id;
12+
use crate::util::serde::humantime_duration;
1213
pub use crate::util::{ByteStringBody, NameOrIdentity};
1314
use crate::{
1415
log_and_500, Action, Authorization, ControlStateDelegate, DatabaseDef, DatabaseResetDef, Host, MaybeMisdirected,
@@ -20,13 +21,14 @@ use axum::response::{ErrorResponse, IntoResponse};
2021
use axum::routing::MethodRouter;
2122
use axum::Extension;
2223
use axum_extra::TypedHeader;
24+
use derive_more::From;
2325
use futures::TryStreamExt;
2426
use http::StatusCode;
2527
use log::{info, warn};
2628
use serde::Deserialize;
2729
use spacetimedb::auth::identity::ConnectionAuthCtx;
2830
use spacetimedb::database_logger::DatabaseLogger;
29-
use spacetimedb::host::module_host::ClientConnectedError;
31+
use spacetimedb::host::module_host::{ClientConnectedError, DurabilityExited};
3032
use spacetimedb::host::{CallResult, UpdateDatabaseResult};
3133
use spacetimedb::host::{FunctionArgs, MigratePlanResult};
3234
use spacetimedb::host::{ModuleHost, ReducerOutcome};
@@ -44,6 +46,9 @@ use spacetimedb_lib::{sats, AlgebraicValue, Hash, ProductValue, Timestamp};
4446
use spacetimedb_schema::auto_migrate::{
4547
MigrationPolicy as SchemaMigrationPolicy, MigrationToken, PrettyPrintStyle as AutoMigratePrettyPrintStyle,
4648
};
49+
use tokio::sync::oneshot;
50+
use tokio::time::error::Elapsed;
51+
use tokio::time::timeout;
4752

4853
use super::subscribe::{handle_websocket, HasWebSocketOptions};
4954

@@ -695,8 +700,33 @@ pub struct PublishDatabaseQueryParams {
695700
parent: Option<NameOrIdentity>,
696701
#[serde(alias = "org")]
697702
organization: Option<NameOrIdentity>,
703+
/// Duration to wait for a database update to become confirmed (i.e. durable).
704+
///
705+
/// The value is parsed via the `humantime` crate, e.g. "1m", "23s", "5min".
706+
///
707+
/// If not given, defaults to [default_update_confirmation_timeout].
708+
/// The maximum timeout is capped by [MAX_UPDATE_CONFIRMATION_TIMEOUT].
709+
///
710+
/// The parameter has no effect when creating a new database.
711+
#[serde(with = "humantime_duration", default = "default_update_confirmation_timeout")]
712+
update_confirmation_timeout: Duration,
713+
}
714+
715+
/// Default timeout for a database update to become confirmed / durable.
716+
///
717+
/// Currently, the value is 5s.
718+
const fn default_update_confirmation_timeout() -> Duration {
719+
Duration::from_secs(5)
698720
}
699721

722+
/// Maximum timeout for a database update to become confirmed / durable.
723+
///
724+
/// If a replication group doesn't converge within this time span, it is
725+
/// probably not making progress at all.
726+
///
727+
/// Currently, the value is 5min.
728+
const MAX_UPDATE_CONFIRMATION_TIMEOUT: Duration = Duration::from_secs(5 * 60);
729+
700730
pub async fn publish<S: NodeDelegate + ControlStateDelegate + Authorization>(
701731
State(ctx): State<S>,
702732
Path(PublishDatabaseParams { name_or_identity }): Path<PublishDatabaseParams>,
@@ -708,6 +738,7 @@ pub async fn publish<S: NodeDelegate + ControlStateDelegate + Authorization>(
708738
host_type,
709739
parent,
710740
organization,
741+
update_confirmation_timeout: confirmation_timeout,
711742
}): Query<PublishDatabaseQueryParams>,
712743
Extension(auth): Extension<SpacetimeAuth>,
713744
program_bytes: Bytes,
@@ -823,23 +854,72 @@ pub async fn publish<S: NodeDelegate + ControlStateDelegate + Authorization>(
823854
.await
824855
.map_err(log_and_500)?;
825856

857+
let success = || {
858+
axum::Json(PublishResult::Success {
859+
domain: db_name.cloned(),
860+
database_identity,
861+
op: publish_op,
862+
})
863+
};
826864
match maybe_updated {
827865
Some(UpdateDatabaseResult::AutoMigrateError(errs)) => {
828866
Err(bad_request(format!("Database update rejected: {errs}").into()))
829867
}
830868
Some(UpdateDatabaseResult::ErrorExecutingMigration(err)) => Err(bad_request(
831869
format!("Failed to create or update the database: {err}").into(),
832870
)),
833-
None
834-
| Some(
835-
UpdateDatabaseResult::NoUpdateNeeded
836-
| UpdateDatabaseResult::UpdatePerformed
837-
| UpdateDatabaseResult::UpdatePerformedWithClientDisconnect,
838-
) => Ok(axum::Json(PublishResult::Success {
839-
domain: db_name.cloned(),
840-
database_identity,
841-
op: publish_op,
842-
})),
871+
None | Some(UpdateDatabaseResult::NoUpdateNeeded) => Ok(success()),
872+
Some(
873+
UpdateDatabaseResult::UpdatePerformed {
874+
tx_offset,
875+
durable_offset,
876+
}
877+
| UpdateDatabaseResult::UpdatePerformedWithClientDisconnect {
878+
tx_offset,
879+
durable_offset,
880+
},
881+
) => {
882+
timeout(confirmation_timeout.min(MAX_UPDATE_CONFIRMATION_TIMEOUT), async {
883+
let tx_offset = tx_offset.await?;
884+
if let Some(mut durable_offset) = durable_offset {
885+
durable_offset.wait_for(tx_offset).await?;
886+
}
887+
888+
Ok::<_, UpdateConfirmationError>(())
889+
})
890+
.await
891+
.map_err(Into::into)
892+
.flatten()?;
893+
894+
Ok(success())
895+
}
896+
}
897+
}
898+
899+
#[derive(From)]
900+
enum UpdateConfirmationError {
901+
Cancelled(oneshot::error::RecvError),
902+
Crashed(DurabilityExited),
903+
Timeout(Elapsed),
904+
}
905+
906+
impl From<UpdateConfirmationError> for ErrorResponse {
907+
fn from(e: UpdateConfirmationError) -> Self {
908+
match e {
909+
UpdateConfirmationError::Cancelled(_) => (
910+
StatusCode::SERVICE_UNAVAILABLE,
911+
"Database update failed: transaction was cancelled",
912+
),
913+
UpdateConfirmationError::Crashed(_) => (
914+
StatusCode::SERVICE_UNAVAILABLE,
915+
"Database update failed: database crashed while waiting for transaction confirmation",
916+
),
917+
UpdateConfirmationError::Timeout(_) => (
918+
StatusCode::GATEWAY_TIMEOUT,
919+
"Database update failed: timeout waiting for transaction confirmation",
920+
),
921+
}
922+
.into()
843923
}
844924
}
845925

0 commit comments

Comments
 (0)