Skip to content

Commit b946479

Browse files
authored
Add lazy table type (#175)
Fixes #83
1 parent b64091e commit b946479

File tree

4 files changed

+341
-0
lines changed

4 files changed

+341
-0
lines changed

internal/frontend/eval.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,9 @@ func (eval *Eval) initZygote() error {
163163
if err := registerDerivationMetatable(ctx, l); err != nil {
164164
return err
165165
}
166+
if err := registerLazyMetatable(ctx, l); err != nil {
167+
return err
168+
}
166169
if err := registerModuleMetatable(ctx, l); err != nil {
167170
return err
168171
}
@@ -179,6 +182,7 @@ func (eval *Eval) initZygote() error {
179182
"await": awaitFunction,
180183
"derivation": eval.derivationFunction,
181184
"import": eval.importFunction,
185+
"lazy": lazyFunction,
182186
"toFile": eval.toFileFunction,
183187
"path": eval.pathFunction,
184188
"readFile": eval.readFileFunction,

internal/frontend/lazy.go

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
// Copyright 2025 The zb Authors
2+
// SPDX-License-Identifier: MIT
3+
4+
package frontend
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"math"
10+
"sync"
11+
12+
"zb.256lights.llc/pkg/internal/lua"
13+
)
14+
15+
const lazyTypeName = "lazy"
16+
17+
const (
18+
lazySentinelRegistryKey = "zb.256lights.llc/pkg/internal/frontend lazy sentinel"
19+
20+
lazyErrorTypeName = "zb.256lights.llc/pkg/internal/frontend lazy error"
21+
lazyProgressTypeName = "zb.256lights.llc/pkg/internal/frontend lazy progress"
22+
)
23+
24+
type lazyTable struct {
25+
mu sync.Mutex
26+
storage lua.State
27+
}
28+
29+
func (lt *lazyTable) Freeze() error {
30+
return nil
31+
}
32+
33+
func registerLazyMetatable(ctx context.Context, l *lua.State) error {
34+
lua.NewMetatable(l, lazyTypeName)
35+
err := lua.SetPureFunctions(ctx, l, 0, map[string]lua.Function{
36+
"__index": indexLazy,
37+
"__metatable": nil, // prevent Lua access to metatable
38+
})
39+
if err != nil {
40+
return err
41+
}
42+
l.Pop(1)
43+
return nil
44+
}
45+
46+
func lazyFunction(ctx context.Context, l *lua.State) (int, error) {
47+
lt := new(lazyTable)
48+
lt.storage.NewUserdata(nil, 0)
49+
if err := lt.storage.RawSetField(lua.RegistryIndex, lazySentinelRegistryKey); err != nil {
50+
return 0, err
51+
}
52+
lua.NewMetatable(&lt.storage, lazyErrorTypeName)
53+
lua.NewMetatable(&lt.storage, lazyProgressTypeName)
54+
lt.storage.Pop(2)
55+
lt.storage.CreateTable(0, 0)
56+
57+
l.SetTop(2)
58+
l.NewUserdata(lt, 1)
59+
l.PushValue(1)
60+
if err := l.SetUserValue(-2, 1); err != nil {
61+
return 0, err
62+
}
63+
64+
// The second argument, if provided, must be a simple table
65+
// that we immediately initialize with values.
66+
if tp := l.Type(2); tp != lua.TypeNil {
67+
if tp != lua.TypeTable {
68+
return 0, lua.NewTypeError(l, 2, lua.TypeTable.String())
69+
}
70+
l.PushNil()
71+
for l.Next(2) {
72+
if !isLazyKey(l, -2) {
73+
l.Pop(1)
74+
continue
75+
}
76+
if err := l.Freeze(-1); err != nil {
77+
keyString, _, _ := lua.ToString(ctx, l, -1)
78+
return 0, fmt.Errorf("%scannot freeze value for %s", lua.Where(l, 1), keyString)
79+
}
80+
81+
l.PushValue(-2) // key
82+
l.Insert(-2)
83+
if err := lt.storage.XMove(l, 2); err != nil {
84+
return 0, err
85+
}
86+
if err := lt.storage.RawSet(1); err != nil {
87+
return 0, err
88+
}
89+
}
90+
}
91+
92+
if err := lua.SetMetatable(l, lazyTypeName); err != nil {
93+
return 0, err
94+
}
95+
96+
return 1, nil
97+
}
98+
99+
func toLazy(l *lua.State) (*lazyTable, error) {
100+
const idx = 1
101+
if _, err := lua.CheckUserdata(l, idx, lazyTypeName); err != nil {
102+
return nil, err
103+
}
104+
lt := testLazy(l, idx)
105+
if lt == nil {
106+
return nil, lua.NewArgError(l, idx, "could not extract lazy")
107+
}
108+
return lt, nil
109+
}
110+
111+
func testLazy(l *lua.State, idx int) *lazyTable {
112+
x, _ := lua.TestUserdata(l, idx, lazyTypeName)
113+
lt, _ := x.(*lazyTable)
114+
return lt
115+
}
116+
117+
func indexLazy(ctx context.Context, l *lua.State) (int, error) {
118+
lt, err := toLazy(l)
119+
if err != nil {
120+
return 0, err
121+
}
122+
if !isLazyKey(l, 2) {
123+
l.PushNil()
124+
return 1, nil
125+
}
126+
127+
// Check in storage.
128+
l.PushValue(2)
129+
lt.mu.Lock()
130+
if err := lt.storage.XMove(l, 1); err != nil {
131+
lt.mu.Unlock()
132+
return 0, err
133+
}
134+
lt.storage.PushValue(-1) // Save copy for placeholder set down below.
135+
if cacheHit, err := lt.lockedCheckCache(ctx); err != nil {
136+
lt.storage.SetTop(1)
137+
lt.mu.Unlock()
138+
keyString, _, _ := lua.ToString(ctx, l, 2)
139+
return 0, fmt.Errorf("%sindex %s: %w", lua.Where(l, 1), keyString, err)
140+
} else if cacheHit {
141+
err := l.XMove(&lt.storage, 1)
142+
lt.storage.SetTop(1)
143+
lt.mu.Unlock()
144+
if err != nil {
145+
return 0, err
146+
}
147+
return 1, nil
148+
}
149+
// First time seeing this index. Add a placeholder.
150+
done := make(chan struct{})
151+
lt.storage.NewUserdata((<-chan struct{})(done), 0)
152+
lua.SetMetatable(&lt.storage, lazyProgressTypeName)
153+
err = lt.storage.RawSet(1)
154+
lt.mu.Unlock()
155+
if err != nil {
156+
return 0, err
157+
}
158+
159+
// Call the function.
160+
// TODO(someday): Preserve error object instead of just string.
161+
l.UserValue(1, 1) // stored function
162+
l.PushValue(1) // lazy table
163+
l.PushValue(2) // key
164+
callError := l.PCall(ctx, 2, 1, 0)
165+
if callError == nil {
166+
if err := l.Freeze(-1); err != nil {
167+
l.Pop(1)
168+
keyString, _, _ := lua.ToString(ctx, l, 2)
169+
callError = fmt.Errorf("%scannot freeze value for %s: %v", lua.Where(l, 1), keyString, err)
170+
}
171+
}
172+
173+
// Store the result.
174+
// The error conditions in this critical section should only trigger if there's a bug.
175+
n := 1
176+
l.PushValue(2) // key
177+
if callError == nil {
178+
l.PushValue(-2) // value
179+
n++
180+
}
181+
lt.mu.Lock()
182+
defer lt.mu.Unlock()
183+
if err := lt.storage.XMove(l, n); err != nil {
184+
return 0, err
185+
}
186+
switch {
187+
case callError != nil:
188+
// Wrap with error object.
189+
lt.storage.NewUserdata(callError, 0)
190+
lua.SetMetatable(&lt.storage, lazyErrorTypeName)
191+
case lt.storage.IsNil(-1):
192+
// Use the sentinel value instead of an actual nil
193+
// to record that we've already called the function for this key.
194+
lt.storage.Pop(1)
195+
lt.storage.RawField(lua.RegistryIndex, lazySentinelRegistryKey)
196+
}
197+
if err := lt.storage.RawSet(1); err != nil {
198+
return 0, err
199+
}
200+
201+
close(done)
202+
return 1, nil
203+
}
204+
205+
// lockedCheckCache checks the lazy table's storage for the key on the top of the stack.
206+
// The key will be popped, then, if lockedCheckCache returns true,
207+
// the value will be pushed in its place.
208+
// The caller must be holding onto lt.mu.
209+
func (lt *lazyTable) lockedCheckCache(ctx context.Context) (bool, error) {
210+
lt.storage.PushValue(-1) // retain key so we can do another fetch for progress
211+
cachedType := lt.storage.RawGet(1)
212+
if cachedType == lua.TypeNil {
213+
lt.storage.Pop(2)
214+
return false, nil
215+
}
216+
if cachedType != lua.TypeUserdata {
217+
lt.storage.Remove(-2)
218+
return true, nil
219+
}
220+
221+
if data, ok := lua.TestUserdata(&lt.storage, -1, lazyProgressTypeName); !ok {
222+
lt.storage.Remove(-2)
223+
} else {
224+
// Another thread is calling the callback function.
225+
lt.storage.Pop(1) // Pop value. Key will be on top.
226+
ready := data.(<-chan struct{})
227+
var temp lua.State
228+
if err := temp.XMove(&lt.storage, 1); err != nil {
229+
return false, err
230+
}
231+
232+
lt.mu.Unlock()
233+
select {
234+
case <-ready:
235+
case <-ctx.Done():
236+
return false, ctx.Err()
237+
}
238+
lt.mu.Lock()
239+
240+
// Push key back on and fetch.
241+
if err := lt.storage.XMove(&temp, 1); err != nil {
242+
return false, err
243+
}
244+
cachedType = lt.storage.RawGet(1)
245+
if cachedType != lua.TypeUserdata {
246+
return true, nil
247+
}
248+
}
249+
250+
lt.storage.RawField(lua.RegistryIndex, lazySentinelRegistryKey)
251+
if lt.storage.RawEqual(-2, -1) {
252+
// We've already called the function for this key and it returned nil.
253+
lt.storage.Pop(1)
254+
lt.storage.PushNil()
255+
return true, nil
256+
}
257+
lt.storage.Pop(1) // sentinel
258+
259+
if data, ok := lua.TestUserdata(&lt.storage, -1, lazyErrorTypeName); ok {
260+
// The callback function raised an error when called.
261+
lt.storage.Pop(1)
262+
return false, data.(error)
263+
}
264+
265+
return true, nil
266+
}
267+
268+
func isLazyKey(l *lua.State, idx int) bool {
269+
switch l.Type(idx) {
270+
case lua.TypeString, lua.TypeBoolean:
271+
return true
272+
case lua.TypeNumber:
273+
n, _ := l.ToNumber(idx)
274+
return !math.IsNaN(n)
275+
default:
276+
return false
277+
}
278+
}

internal/frontend/lazy_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Copyright 2025 The zb Authors
2+
// SPDX-License-Identifier: MIT
3+
4+
package frontend
5+
6+
import (
7+
"testing"
8+
9+
"github.com/google/go-cmp/cmp"
10+
"zb.256lights.llc/pkg/internal/backendtest"
11+
"zb.256lights.llc/pkg/internal/testcontext"
12+
"zb.256lights.llc/pkg/internal/zbstorerpc"
13+
)
14+
15+
func TestLazy(t *testing.T) {
16+
ctx, cancel := testcontext.New(t)
17+
defer cancel()
18+
storeDir := backendtest.NewStoreDirectory(t)
19+
20+
di := new(zbstorerpc.DeferredImporter)
21+
_, store, err := backendtest.NewServer(ctx, t, storeDir, &backendtest.Options{
22+
TempDir: t.TempDir(),
23+
ClientOptions: zbstorerpc.CodecOptions{
24+
Importer: di,
25+
},
26+
})
27+
if err != nil {
28+
t.Fatal(err)
29+
}
30+
eval, err := NewEval(&Options{
31+
Store: newTestRPCStore(store, di),
32+
StoreDirectory: storeDir,
33+
})
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
defer func() {
38+
if err := eval.Close(); err != nil {
39+
t.Error("eval.Close:", err)
40+
}
41+
}()
42+
43+
expr := `lazy(function(fib, i) if math.type(i) ~= "integer" or i < 3 then return nil end; return fib[i-2] + fib[i-1]; end, {0, 1})[10]`
44+
got, err := eval.Expression(ctx, expr)
45+
if err != nil {
46+
t.Fatalf("%s: %v", expr, err)
47+
}
48+
if diff := cmp.Diff(int64(34), got); diff != "" {
49+
t.Errorf("%s (-want +got):\n%s", expr, diff)
50+
}
51+
}

zb_defs.lua

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ function extract(args) end
7373
---@return derivation
7474
function fetchArchive(args) end
7575

76+
---Return a table whose fields are initialized lazily by calling f.
77+
---@generic K: string|boolean|number
78+
---@generic V
79+
---@param f fun(t: table<K, V>, k: K): V?
80+
---@param init? table<K, V>
81+
---@return table<K, V>
82+
function lazy(f, init) end
83+
7684
os = {}
7785

7886
---Returns the value of the process environment variable `varname`

0 commit comments

Comments
 (0)