Skip to content

Commit 7f0923d

Browse files
lizthegreyclaude
andcommitted
fix: address review findings on the Telemetry API implementation
Four problems, all reachable in normal use: Delivery used http.DefaultClient, which has no timeout. Batches that reach a size limit are delivered on the goroutine producing the event, so a subscriber that accepted a connection and never answered would stall the sandbox's event pipeline. Delivery now uses a client that gives up. platform.report was emitted with status success unconditionally, including from the invoke-timeout path, so a timed-out invocation was reported as a successful one -- the opposite of what an extension watching that field needs. The status is now threaded through. The report's memory metrics were serialized as JSON strings, because the memory size arrives as one. The Telemetry API defines them as numbers, and a consumer decoding into a typed struct rejects strings. They are parsed now, and the report also carries initDurationMs on a cold start, as a real function's does. Dispatch decided whether to buffer or deliver in one lock section and acted in another. A Subscribe interleaving between the two would insert itself, replay a copy of the buffer that did not yet hold the event, and leave the event buffered for nobody -- precisely the ordering the buffer exists to handle. The decision now happens under a single held lock. Tests cover the timeout, the status, the numeric metrics and the cold-start figure. The lost-event window is not covered: the two lock sections it needed to interleave between were adjacent and 400 attempts never hit it, so that fix rests on reading the code rather than on a failing test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0503f0a commit 7f0923d

5 files changed

Lines changed: 206 additions & 27 deletions

File tree

internal/lambda/rie/handlers.go

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -68,30 +68,52 @@ func GetenvWithDefault(key string, defaultValue string) string {
6868
return envValue
6969
}
7070

71-
func printEndReports(invokeId string, initDuration string, memorySize string, invokeStart time.Time, timeoutDuration time.Duration) {
71+
// reportRecord builds the platform.report record. The metrics are numbers, as the
72+
// Telemetry API defines them: a consumer decoding into a typed struct rejects the
73+
// strings these values arrive as.
74+
func reportRecord(invokeId string, status string, invokeDuration float64, memorySize string, initDurationMs float64) map[string]interface{} {
75+
// The emulator cannot measure memory actually used, so it reports the
76+
// configured size for both, as the printed REPORT line does.
77+
memorySizeMB, err := strconv.Atoi(memorySize)
78+
if err != nil {
79+
log.Warnf("AWS_LAMBDA_FUNCTION_MEMORY_SIZE is %q, which is not a number", memorySize)
80+
}
81+
82+
metrics := map[string]interface{}{
83+
"durationMs": invokeDuration,
84+
"billedDurationMs": math.Ceil(invokeDuration),
85+
"memorySizeMB": memorySizeMB,
86+
"maxMemoryUsedMB": memorySizeMB,
87+
}
88+
// Present only on the report for an invocation that initialized the
89+
// environment, as it is in telemetry from a real function.
90+
if initDurationMs > 0 {
91+
metrics["initDurationMs"] = initDurationMs
92+
}
93+
94+
return map[string]interface{}{
95+
"requestId": invokeId,
96+
"status": status,
97+
"metrics": metrics,
98+
}
99+
}
100+
101+
// printEndReports reports the end of an invocation. status is what the Telemetry
102+
// API calls it: "success", or "timeout" when the invocation ran out of time.
103+
func printEndReports(invokeId string, initDuration string, memorySize string, invokeStart time.Time, timeoutDuration time.Duration, status string, initDurationMs float64) {
72104
// Calcuation invoke duration
73105
invokeDuration := math.Min(float64(time.Now().Sub(invokeStart).Nanoseconds()),
74106
float64(timeoutDuration.Nanoseconds())) / float64(time.Millisecond)
75107

76108
fmt.Println("END RequestId: " + invokeId)
77109

78110
if telemetryEvents != nil {
79-
now := time.Now().Format(time.RFC3339)
80111
// platform.end is deliberately not emitted: it is absent from telemetry
81112
// captured off a real function under the current schema version.
82113
telemetryEvents.Dispatch(standalonetelemetry.SandboxEvent{
83-
Time: now,
84-
Type: "platform.report",
85-
PlatformEvent: map[string]interface{}{
86-
"requestId": invokeId,
87-
"status": "success",
88-
"metrics": map[string]interface{}{
89-
"durationMs": invokeDuration,
90-
"billedDurationMs": math.Ceil(invokeDuration),
91-
"memorySizeMB": memorySize,
92-
"maxMemoryUsedMB": memorySize,
93-
},
94-
},
114+
Time: time.Now().Format(time.RFC3339),
115+
Type: "platform.report",
116+
PlatformEvent: reportRecord(invokeId, status, invokeDuration, memorySize, initDurationMs),
95117
})
96118
}
97119
// We set the Max Memory Used and Memory Size to be the same (whatever it is set to) since there is
@@ -123,6 +145,7 @@ func InvokeHandler(w http.ResponseWriter, r *http.Request, sandbox Sandbox, bs i
123145
}
124146

125147
initDuration := ""
148+
initDurationMs := float64(0)
126149
inv := GetenvWithDefault("AWS_LAMBDA_FUNCTION_TIMEOUT", "300")
127150
timeoutDuration, _ := time.ParseDuration(inv + "s")
128151
// Default
@@ -143,6 +166,7 @@ func InvokeHandler(w http.ResponseWriter, r *http.Request, sandbox Sandbox, bs i
143166
float64(timeoutDuration.Nanoseconds())) / float64(time.Millisecond)
144167

145168
initDuration = fmt.Sprintf("Init Duration: %.2f ms\t", initTimeMS)
169+
initDurationMs = initTimeMS
146170

147171
// Set initDone so next invokes do not try to Init the function again
148172
initDone = true
@@ -229,7 +253,7 @@ func InvokeHandler(w http.ResponseWriter, r *http.Request, sandbox Sandbox, bs i
229253
w.WriteHeader(http.StatusGatewayTimeout)
230254
return
231255
case rapidcore.ErrInvokeTimeout:
232-
printEndReports(invokePayload.ID, initDuration, memorySize, invokeStart, timeoutDuration)
256+
printEndReports(invokePayload.ID, initDuration, memorySize, invokeStart, timeoutDuration, "timeout", initDurationMs)
233257

234258
w.Write([]byte(fmt.Sprintf("Task timed out after %d.00 seconds", timeout)))
235259
time.Sleep(100 * time.Millisecond)
@@ -238,7 +262,7 @@ func InvokeHandler(w http.ResponseWriter, r *http.Request, sandbox Sandbox, bs i
238262
}
239263
}
240264

241-
printEndReports(invokePayload.ID, initDuration, memorySize, invokeStart, timeoutDuration)
265+
printEndReports(invokePayload.ID, initDuration, memorySize, invokeStart, timeoutDuration, "success", initDurationMs)
242266

243267
if invokeResp.StatusCode != 0 {
244268
w.WriteHeader(invokeResp.StatusCode)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package rie
5+
6+
import (
7+
"encoding/json"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// The Telemetry API defines these metrics as numbers. A consumer decoding into a
15+
// typed struct rejects the strings the memory size arrives as, so the record has
16+
// to carry numbers however the value reached us.
17+
func TestReportRecordMetricsAreNumbers(t *testing.T) {
18+
encoded, err := json.Marshal(reportRecord("abc", "success", 12.5, "512", 0))
19+
require.NoError(t, err)
20+
21+
var decoded struct {
22+
RequestID string `json:"requestId"`
23+
Status string `json:"status"`
24+
Metrics struct {
25+
DurationMs float64 `json:"durationMs"`
26+
BilledDurationMs float64 `json:"billedDurationMs"`
27+
MemorySizeMB int `json:"memorySizeMB"`
28+
MaxMemoryUsedMB int `json:"maxMemoryUsedMB"`
29+
} `json:"metrics"`
30+
}
31+
require.NoError(t, json.Unmarshal(encoded, &decoded), "a typed consumer must be able to decode this")
32+
33+
assert.Equal(t, "abc", decoded.RequestID)
34+
assert.Equal(t, "success", decoded.Status)
35+
assert.Equal(t, 12.5, decoded.Metrics.DurationMs)
36+
assert.Equal(t, float64(13), decoded.Metrics.BilledDurationMs)
37+
assert.Equal(t, 512, decoded.Metrics.MemorySizeMB)
38+
}
39+
40+
// An invocation that ran out of time is not a successful one. Extensions alarm on
41+
// this field, so reporting a timeout as a success would hide exactly the failure
42+
// they are watching for.
43+
func TestReportRecordCarriesTheInvocationStatus(t *testing.T) {
44+
for _, status := range []string{"success", "timeout"} {
45+
t.Run(status, func(t *testing.T) {
46+
assert.Equal(t, status, reportRecord("abc", status, 1, "128", 0)["status"])
47+
})
48+
}
49+
}
50+
51+
// A memory size that isn't a number should not make the record undecodable.
52+
func TestReportRecordSurvivesAnUnparseableMemorySize(t *testing.T) {
53+
record := reportRecord("abc", "success", 1, "not-a-number", 0)
54+
metrics := record["metrics"].(map[string]interface{})
55+
assert.Equal(t, 0, metrics["memorySizeMB"])
56+
}
57+
58+
// A cold start reports how long initialization took; a warm invocation has no
59+
// such figure and must not report a zero one.
60+
func TestReportRecordIncludesInitDurationOnlyOnColdStart(t *testing.T) {
61+
cold := reportRecord("abc", "success", 1, "128", 120.5)["metrics"].(map[string]interface{})
62+
assert.Equal(t, 120.5, cold["initDurationMs"])
63+
64+
warm := reportRecord("abc", "success", 1, "128", 0)["metrics"].(map[string]interface{})
65+
assert.NotContains(t, warm, "initDurationMs")
66+
}

internal/lambda/rie/http.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ package rie
66
import (
77
"net/http"
88

9-
log "github.com/sirupsen/logrus"
109
"github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/interop"
1110
"github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/rapidcore"
11+
log "github.com/sirupsen/logrus"
1212
)
1313

1414
func startHTTPServer(ipport string, sandbox *rapidcore.SandboxBuilder, bs interop.Bootstrap) {

internal/lambda/rie/telemetry_subscription.go

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -172,28 +172,30 @@ func (s *TelemetrySubscriptionService) Subscribe(agentName string, body io.Reade
172172
// soon as initialization finishes, which stops new subscriptions but must leave
173173
// delivery to existing ones running for the life of the environment.
174174
func (s *TelemetrySubscriptionService) Dispatch(event standalonetelemetry.SandboxEvent) {
175-
s.lock.Lock()
176-
subscriptions := make([]*subscription, 0, len(s.subscriptions))
177-
for _, sub := range s.subscriptions {
178-
subscriptions = append(subscriptions, sub)
179-
}
180-
s.lock.Unlock()
181-
182175
record := telemetryRecord{Time: event.Time, Type: event.Type}
183176
if event.PlatformEvent != nil {
184177
record.Record = event.PlatformEvent
185178
} else {
186179
record.Record = event.LogMessage
187180
}
188181

189-
if len(subscriptions) == 0 {
190-
s.lock.Lock()
182+
// Buffering and delivery are chosen under a single held lock. Deciding in one
183+
// lock section and acting in another lets a Subscribe interleave: it would
184+
// insert itself and replay a copy of the buffer that does not yet hold this
185+
// event, and the event would then be buffered for nobody.
186+
s.lock.Lock()
187+
if len(s.subscriptions) == 0 {
191188
if len(s.earlyEvents) < maxEarlyEvents {
192189
s.earlyEvents = append(s.earlyEvents, record)
193190
}
194191
s.lock.Unlock()
195192
return
196193
}
194+
subscriptions := make([]*subscription, 0, len(s.subscriptions))
195+
for _, sub := range s.subscriptions {
196+
subscriptions = append(subscriptions, sub)
197+
}
198+
s.lock.Unlock()
197199

198200
for _, sub := range subscriptions {
199201
if sub.wants(event.Type) {
@@ -262,7 +264,7 @@ func (sub *subscription) deliver(batch []telemetryRecord) {
262264
log.WithError(err).Warn("Telemetry API: could not encode a batch")
263265
return
264266
}
265-
response, err := http.Post(sub.destination, "application/json", bytes.NewReader(body))
267+
response, err := deliveryClient.Post(sub.destination, "application/json", bytes.NewReader(body))
266268
if err != nil {
267269
log.WithError(err).Warnf("Telemetry API: could not deliver to %s", sub.destination)
268270
return
@@ -340,6 +342,12 @@ func (s *TelemetrySubscriptionService) GetServiceClosedErrorType() string {
340342

341343
const telemetryEndpointPath = "/2022-07-01/telemetry"
342344

345+
// Batches that reach a size limit are delivered on the goroutine producing the
346+
// event, so a subscriber that accepts a connection and never answers would stall
347+
// the sandbox's event pipeline. The default client has no timeout; this one gives
348+
// up instead.
349+
var deliveryClient = &http.Client{Timeout: 5 * time.Second}
350+
343351
// resolveDestination rewrites the hostname extensions are told to use. Inside a
344352
// real execution environment "sandbox" resolves to the host running the runtime;
345353
// in the emulator everything shares one network namespace.

internal/lambda/rie/telemetry_subscription_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,84 @@ func TestSandboxHostIsRewritten(t *testing.T) {
284284
})
285285
}
286286
}
287+
288+
// Exercises Subscribe and Dispatch concurrently, for the race detector and to
289+
// confirm the event still arrives.
290+
//
291+
// This does not reproduce the lost-event window that motivated deciding
292+
// buffer-versus-deliver under a single lock: the two lock sections it needed to
293+
// interleave between were adjacent, and 400 attempts never hit it. The
294+
// correctness of that arrangement rests on reading the code, not on this test.
295+
func TestConcurrentSubscribeAndDispatch(t *testing.T) {
296+
for attempt := 0; attempt < 100; attempt++ {
297+
service := NewTelemetrySubscriptionService()
298+
extension := newReceiver(t)
299+
300+
var wg sync.WaitGroup
301+
wg.Add(2)
302+
go func() {
303+
defer wg.Done()
304+
service.Dispatch(platformEvent("platform.initStart", map[string]interface{}{"phase": "init"}))
305+
}()
306+
go func() {
307+
defer wg.Done()
308+
subscribe(t, service, "ext", extension.server.URL, []string{"platform"})
309+
}()
310+
wg.Wait()
311+
312+
// Whichever order they interleaved in, the event is either delivered or
313+
// still buffered for the next subscriber. It must not be stranded.
314+
deadline := time.Now().Add(2 * time.Second)
315+
var seen bool
316+
for time.Now().Before(deadline) && !seen {
317+
for _, event := range extension.received() {
318+
if event["type"] == "platform.initStart" {
319+
seen = true
320+
}
321+
}
322+
if !seen {
323+
time.Sleep(10 * time.Millisecond)
324+
}
325+
}
326+
if !seen {
327+
t.Fatalf("attempt %d: initStart reached no subscriber", attempt)
328+
}
329+
}
330+
}
331+
332+
// A subscriber that accepts a connection and never answers must not stall the
333+
// sandbox: delivery happens on the event-producing goroutine when a batch fills.
334+
func TestDeliveryToAHungSubscriberDoesNotBlockForever(t *testing.T) {
335+
blocked := make(chan struct{})
336+
337+
hung := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
338+
<-blocked // never answers while the test runs
339+
}))
340+
// Close waits for handlers in flight, so the handler has to be released
341+
// first. Defers run last-in-first-out, hence this order.
342+
defer hung.Close()
343+
defer close(blocked)
344+
345+
service := NewTelemetrySubscriptionService()
346+
body, _ := json.Marshal(map[string]interface{}{
347+
"types": []string{"function"},
348+
"buffering": map[string]int{"timeoutMs": 60000, "maxItems": 1, "maxBytes": 262144},
349+
"destination": map[string]string{"protocol": "HTTP", "URI": hung.URL},
350+
})
351+
_, status, _, err := service.Subscribe("ext", strings.NewReader(string(body)), nil, "")
352+
require.NoError(t, err)
353+
require.Equal(t, http.StatusOK, status)
354+
355+
// maxItems of 1 means this dispatch delivers synchronously.
356+
done := make(chan struct{})
357+
go func() {
358+
service.Dispatch(logEvent("function", "hello"))
359+
close(done)
360+
}()
361+
362+
select {
363+
case <-done:
364+
case <-time.After(deliveryClient.Timeout + 5*time.Second):
365+
t.Fatal("Dispatch never returned; a hung subscriber can stall the event pipeline")
366+
}
367+
}

0 commit comments

Comments
 (0)