Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@ jobs:
fail-fast: false
matrix:
dart: [3.6, 3.12]
package: [cli_tools, config, isolated_object, serverpod_logging]
package:
- cli_tools
- config
- isolated_object
- serverpod_logging
- serverpod_logging_cli
runs-on: ubuntu-latest
defaults:
run:
Expand All @@ -35,7 +40,12 @@ jobs:
fail-fast: false
matrix:
dart: [3.6, 3.12]
package: [cli_tools, config, isolated_object, serverpod_logging]
package:
- cli_tools
- config
- isolated_object
- serverpod_logging
- serverpod_logging_cli
platform: [ubuntu-latest]
include:
- package: cli_tools
Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/publish-serverpod_logging_cli.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Publish serverpod_logging_cli package

on:
push:
tags:
# Matches tags like serverpod_logging_cli-v1.2.3 and serverpod_logging_cli-v1.2.3-pre.1
- 'serverpod_logging_cli-v[0-9]+.[0-9]+.[0-9]+'
- 'serverpod_logging_cli-v[0-9]+.[0-9]+.[0-9]+-*'

jobs:
publish:
permissions:
id-token: write
uses: dart-lang/setup-dart/.github/workflows/publish.yml@v1
with:
working-directory: packages/serverpod_logging_cli
6 changes: 6 additions & 0 deletions packages/serverpod_logging/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 0.1.0

- Add `IsolatedLogWriter`, a `LogWriter` that runs a wrapped writer on a
dedicated isolate (via `package:isolated_object`).
- Lower the SDK constraint to `^3.6.0`.

## 0.0.1

- Initial version.
1 change: 1 addition & 0 deletions packages/serverpod_logging/lib/serverpod_logging.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export 'src/global_log.dart';
export 'src/isolated_log_writer.dart';
export 'src/log.dart';
export 'src/log_types.dart';
export 'src/spinner_log_writer.dart';
Expand Down
42 changes: 42 additions & 0 deletions packages/serverpod_logging/lib/src/isolated_log_writer.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import 'package:isolated_object/isolated_object.dart';

import 'log_types.dart';

/// A [LogWriter] that runs a wrapped writer on a dedicated isolate.
///
/// Not a high-throughput sink. Every operation is copied across the isolate
/// boundary, so payloads must be sendable.
class IsolatedLogWriter extends IsolatedObject<LogWriter> implements LogWriter {
/// Creates an [IsolatedLogWriter] that runs the writer produced by
/// [factory] on a dedicated isolate.
IsolatedLogWriter(super.factory);

@override
Future<void> log(LogEntry entry) async {
try {
await evaluate((w) => w.log(entry));
} catch (_) {} // best effort
}

@override
Future<void> openScope(LogScope scope) => evaluate((w) => w.openScope(scope));

@override
Future<void> closeScope(
LogScope scope, {
required bool success,
required Duration duration,
Object? error,
StackTrace? stackTrace,
}) async {
await evaluate(
(w) => w.closeScope(
scope,
success: success,
duration: duration,
error: error,
stackTrace: stackTrace,
),
);
}
}
128 changes: 128 additions & 0 deletions packages/serverpod_logging/test/isolated_log_writer_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import 'package:serverpod_logging/serverpod_logging.dart';
import 'package:test/test.dart';

LogEntry _entry(String message) => LogEntry(
time: DateTime.now(),
level: LogLevel.info,
message: message,
scope: LogScope.root('r'),
);

/// A writer whose [log] is slow, so a write is reliably in-flight when the
/// isolate is asked to close.
class SlowWriter extends LogWriter {
@override
Future<void> log(LogEntry entry) async =>
Future<void>.delayed(const Duration(milliseconds: 100));

@override
Future<void> openScope(LogScope scope) async {}

@override
Future<void> closeScope(
LogScope scope, {
required bool success,
required Duration duration,
Object? error,
StackTrace? stackTrace,
}) async {}
}

/// A writer whose [log] fails after a delay, so a rejecting write is reliably
/// in-flight when the isolate is asked to close.
class SlowFailingWriter extends LogWriter {
@override
Future<void> log(LogEntry entry) async {
await Future<void>.delayed(const Duration(milliseconds: 100));
throw StateError('write failed');
}

@override
Future<void> openScope(LogScope scope) async {}

@override
Future<void> closeScope(
LogScope scope, {
required bool success,
required Duration duration,
Object? error,
StackTrace? stackTrace,
}) async {}
}

void main() {
group('Given an IsolatedLogWriter wrapping a TestLogWriter', () {
test(
'when an entry is logged, '
'then it is forwarded to the writer running in the isolate',
() async {
final writer = IsolatedLogWriter(() => TestLogWriter());

await writer.log(_entry('hi'));

// Read the isolate-local writer's state back across the boundary.
final messages = await writer.evaluate(
(w) => (w as TestLogWriter).entries.map((e) => e.message).toList(),
);
expect(messages, ['hi']);

await writer.close();
},
);

test(
'when a scope is opened and closed, '
'then both are forwarded to the writer running in the isolate',
() async {
final writer = IsolatedLogWriter(() => TestLogWriter());
final scope = LogScope.root('op');

await writer.openScope(scope);
await writer.closeScope(scope, success: true, duration: Duration.zero);

final opened = await writer.evaluate(
(w) => (w as TestLogWriter).openedScopes.length,
);
final closed = await writer.evaluate(
(w) => (w as TestLogWriter).closedScopes.length,
);
expect(opened, 1);
expect(closed, 1);

await writer.close();
});
});

group('Given an IsolatedLogWriter lifecycle', () {
test(
'when closed with a write in flight, '
'then the in-flight write is swallowed and its log future completes',
() async {
final writer = IsolatedLogWriter(() => SlowWriter());

final pending = writer.log(_entry('slow')); // intentionally not awaited
await writer.close();

await expectLater(pending, completes);
});

test(
'when a wrapped write fails while in flight, '
'then close() still completes (does not rethrow the failure)',
() async {
final writer = IsolatedLogWriter(() => SlowFailingWriter());

// ignore: unawaited_futures - fire-and-forget, like Log.call.
writer.log(_entry('boom')).catchError((_) {});
await expectLater(writer.close(), completes);
},
);

test('when log is called after close, then it is a no-op', () async {
final writer = IsolatedLogWriter(() => TestLogWriter());
await writer.close();

await expectLater(writer.log(_entry('x')), completes);
});
});
}
Loading
Loading